initial commit

This commit is contained in:
iceBear67
2026-07-15 14:28:58 +08:00
commit 6e0d7ec33f
47 changed files with 4022 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# Keep the Docker build context small: the client image only needs the Go module.
.git
.github
bin/
server/
e2e/
docs/
scripts/
*.md
CURRENT_MC_PROTO.txt
.gitignore
@@ -0,0 +1,70 @@
name: publish-client-image
# Builds the redapricot Go client into a container image with ko and pushes it
# to the configured registry. Triggers on version tags and manual dispatch.
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Extra image tag to publish (in addition to the git ref)'
required: false
default: ''
permissions:
contents: read
packages: write # required only when KO_DOCKER_REPO is on ghcr.io
env:
# ─────────────────────────────────────────────────────────────────────────────
# EDIT ME — the image repository to publish to: <registry>/<namespace>/<name>
# examples:
# ghcr.io/your-org/redapricot-client
# docker.io/youruser/redapricot-client
# registry.example.com/team/redapricot-client
# ─────────────────────────────────────────────────────────────────────────────
KO_DOCKER_REPO: REPLACE_ME_REGISTRY/redapricot-client
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- uses: ko-build/setup-ko@v0.10
- name: Derive registry host and image tags
id: meta
run: |
echo "registry=${KO_DOCKER_REPO%%/*}" >> "$GITHUB_OUTPUT"
tags="${GITHUB_REF_NAME},sha-${GITHUB_SHA::7}"
if [ -n "${{ github.event.inputs.tag }}" ]; then
tags="${tags},${{ github.event.inputs.tag }}"
fi
echo "tags=${tags}" >> "$GITHUB_OUTPUT"
# Authenticate to the registry.
# • ghcr.io: setup-ko already logs in with the built-in GITHUB_TOKEN, so you
# may delete this step.
# • any other registry: set the REGISTRY_USERNAME / REGISTRY_PASSWORD repo
# secrets; the host is derived from KO_DOCKER_REPO above.
- name: Log in to the container registry
uses: docker/login-action@v3
with:
registry: ${{ steps.meta.outputs.registry }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push image with ko
run: |
ko build ./cmd/redapricot-client \
--bare \
--platform=linux/amd64,linux/arm64 \
--tags "${{ steps.meta.outputs.tags }}"
+13
View File
@@ -0,0 +1,13 @@
# Java / Gradle
server/build/
server/.gradle/
.gradle/
# Go build output
/bin/
# Editor / OS
*.log
.DS_Store
.idea/
*.iml
+13
View File
@@ -0,0 +1,13 @@
# ko (https://ko.build) build configuration for the redapricot Go client.
# ko builds the container image directly from Go source — no Dockerfile needed.
# The registry is supplied via the KO_DOCKER_REPO env var (see the workflow).
defaultBaseImage: cgr.dev/chainguard/static:latest
builds:
- id: redapricot-client
dir: .
main: ./cmd/redapricot-client
env:
- CGO_ENABLED=0
ldflags:
- -s
- -w
+31
View File
@@ -0,0 +1,31 @@
# redapricot Go client image.
#
# Build from the repository root (the Go module lives here):
# docker build -t redapricot-client .
#
# Run with a mounted config:
# docker run --rm -v "$PWD/client.json:/etc/redapricot/client.json" \
# redapricot-client /etc/redapricot/client.json
#
# syntax=docker/dockerfile:1
# ---- build stage ----
FROM golang:1.25 AS build
WORKDIR /src
# Cache module downloads separately from the source.
COPY go.mod go.sum ./
RUN go mod download
# Build a static, stripped binary.
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \
-o /out/redapricot-client ./cmd/redapricot-client
# ---- runtime stage ----
FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/redapricot-client /usr/local/bin/redapricot-client
USER nonroot:nonroot
ENTRYPOINT ["/usr/local/bin/redapricot-client"]
# Default config path; override by passing a different path as the container arg.
CMD ["/etc/redapricot/client.json"]
+40
View File
@@ -0,0 +1,40 @@
redapricot (红杏) 针对 Minecraft 协议实现 central-hub 模式的 P2P 连接工具。
角色有三:
1. 服务器, 即红杏服务端,负责处理各种用户请求接入
2. 客户端, 负责跟服务器注册信息并且接受经过服务器转发的玩家连接到其他目的地上。
3. 玩家,使用 Minecraft 协议连接服务器的用户及软件,是服务器识别且匹配转发的首要对象。
# 协议工作流程
本协议是在 @CURRENT_MC_PROTO.txt(注意此文件巨大,采自 https://minecraft.wiki/w/Java_Edition_protocol/Packets 语境下对 Minecraft 协议的扩展。本协议有最基本的安全检查,但比起安全性更关注连接性问题本身,因此不安全的协议设计并不是待解决的问题。
- 客户端向服务器发送 Intent 为 17 的 Handshake 包进入红杏专属协议,且 Server Address 设置为 PSK 的 SHA3-224 哈希。在此封包后,所有的数据包均使用 PSK 进行加密,加密算法你可以自己选,也可以参考 MC Protocol Encryption 的流程
- 启用加密后,客户端发送魔数 0x01 、一串随机数、当前时间作为新的PSK。服务端检查时间后,使用该 PSK 作为新的密钥直到连接断开。
- 在服务器确定了客户端的身份后,此连接将作为控制会话。控制会话不直接传递游戏数据。
- 客户端可以在任意时刻向服务器发送注册请求。注册请求中包含一个大小写不敏感的 Server Address,记作 PATTERN。
- 玩家通过 Minecraft 连接服务器,服务器会匹配 Handshake 包中的 Intent 以及 Server Address. 对于 Intent != 17 且 Intent != 18 的 Handshake, 匹配 ServerAddress 有无命中的 PATTERN. 如果有,将此连接编号为 CID(CID 的生成必须是随机的),向客户端发送控制请求(PATTERN, 玩家IP, CID)。
- 收到控制请求的客户端需要匹配一个在线的 Worker Conn,然后使用 Worker Conn 跟服务端申请接管该连接(发送 CID)。Worker Conn 是实现多路复用的数据转发链路,具体见下文。
- 服务端将 Handshake 原样转发给 WorkerConn, Worker Conn 通过可选的 HAPROXY v2 协议将报文转发给目的地,并且开启双向转发。
- 当玩家断开连接时,服务器会给 Worker Conn 发送一条 disconnect 来关闭多路复用中对应的流。
# Worker Conn
Worker Conn 是实现了多路复用协议的 TCP 连接。建立过程如下:
- 按照上文所述的验证方法验证。只不过第二步魔数使用 0x02 而不是 0x01 来表示这是一个 worker conn
- Worker Conn 采用某种多路复用协议,该协议的最小操作单位是流。
多路复用协议的开销应该尽可能的少以在效率上达成最大收益。用户会通过配置文件设置 1 <= max_conn <= 8, stream 分配算法会总是在已有的 conn 簇里寻找活跃 stream 最少的 conn 进行分配。如果所有 conn 均饱和了,则在不超过 max_conn 的情况下创建一个新的 conn 并且在上分配。饱和指 stream 的数量 > 8。
worker conn 可以考虑使用类似 chacha20 的流密码算法以最小化 padding 带来的大小开销。
# Technical details
服务端必须使用 Java 编写,并且偏好于使用 Vert.x。客户端使用 Golang 编写。
注意:由于你现在在一个特殊的沙盒里,环境变量可能会出一些问题。如果有那样的问题,记住 Java 在 ~/.sdkman/candidates/current/ (JAVA_HOME).
+334
View File
@@ -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]` |
```
+225
View File
@@ -0,0 +1,225 @@
# redapricot (红杏)
A **central-hub P2P tunnel that speaks the Minecraft Java Edition protocol.**
redapricot lets a Minecraft server that lives behind NAT/CGNAT (a "client")
publish itself through a public **hub**, so that players connecting to the hub
are transparently forwarded to the hidden server — no port forwarding required.
It works by *extending* the Minecraft handshake: the hub listens on a single
port and tells players apart from tunnel endpoints by the handshake `Intent`
field, so a vanilla Minecraft client needs no modification.
> 红杏出墙 — "the red apricot reaches over the wall": a server behind a wall,
> made reachable from the outside.
```
Player ──MC──▶ Hub (Java) ══ Worker Conn (mux) ══▶ Client (Go) ──MC──▶ Real MC server
vanilla client public IP encrypted, pooled behind NAT (localhost)
│ ▲
└────────────── Control Session ─────────┘
(pattern registration + control requests)
```
* **Hub / server** — Java 21+, [Vert.x](https://vertx.io). One public TCP port.
* **Client** — Go. Registers hostnames with the hub and forwards to real servers.
* **Player** — any Minecraft client. Connects to the hub using a registered
hostname; the connection lands on the hidden server.
The full wire protocol is specified in **[PROTOCOL.md](PROTOCOL.md)**; the
design rationale is in **[docs/architecture.md](docs/architecture.md)**.
---
## How it works (in one paragraph)
A client opens a **control session** to the hub: it sends a Minecraft handshake
with `Intent = 17` and a `Server Address` equal to `hex(SHA3-224(PSK))`, then
the link switches to ChaCha20-encrypted frames keyed by the shared **PSK**,
re-keyed to a per-connection secret. Over that session the client **registers**
one or more hostname patterns. When a player connects to the hub with a matching
hostname (and any normal `Intent`), the hub assigns a random **CID**, buffers
the player's bytes, and asks the client (via the control session) to take over.
The client picks a **worker connection** — a multiplexed, encrypted TCP link
that carries many players as lightweight *streams* — opens a stream for that CID,
dials the real destination (optionally announcing the player's real IP with the
**HAProxy v2** protocol), and bridges the two ends. Worker connections are
pooled: the client uses up to `maxConn` of them and always places a new stream
on the least-loaded one.
## Repository layout
```
PROTOCOL.md normative wire spec (read this to build another impl)
docs/architecture.md design, sequence diagrams, threading, limitations
server/ Java hub (Gradle, Vert.x)
src/main/java/io/icybear/redapricot/
client/ Go client library
wire/ VarInt/MC codec, SHA3+ChaCha20, encrypted framing
cmd/redapricot-client/ Go client binary
e2e/ end-to-end integration tests (Java hub + Go client)
scripts/build.sh build hub + client
scripts/e2e.sh build, then run all tests
```
## Prerequisites
* **JDK 21+** (the hub compiles at Java 21; it runs fine on newer JDKs).
* **Gradle 8.5+ / 9.x** (Gradle 9.2.1 is used here).
* **Go 1.24+** (needs the standard-library `crypto/sha3`; developed with Go 1.26).
If you use [SDKMAN!](https://sdkman.io), `scripts/build.sh` auto-discovers a JDK
at `~/.sdkman/candidates/java/current` and Gradle at
`~/.sdkman/candidates/gradle/current`. Otherwise set `JAVA_HOME` and make
`gradle` / `go` available on `PATH`.
## Build
```bash
./scripts/build.sh
```
This produces:
* the hub at `server/build/install/redapricot-server/bin/redapricot-server`
* the client binary at `bin/redapricot-client`
Alternatively, build a single self-contained **fat jar** for the hub (via the
[Shadow](https://gradleup.com/shadow/) plugin):
```bash
gradle -p server shadowJar # (with JAVA_HOME set)
java -jar server/build/libs/redapricot-server-0.1.0-all.jar hub.json
```
## Run
**1. Start the hub** (public machine). Copy and edit the example config:
```bash
cp server/config.example.json hub.json # set a strong "psk"
server/build/install/redapricot-server/bin/redapricot-server hub.json
```
On startup the hub logs its PSK handshake address, e.g.
`PSK handshake address: 90188f2d...` — this confirms the PSK the hub expects.
**2. Start the client** (machine next to the real Minecraft server). Edit the
example config so `psk` matches the hub, `server` points at the hub, and each
mapping routes a hostname to a real server:
```bash
cp client/config.example.json client.json
# {
# "server": "hub.example.com:25565",
# "psk": "same-as-the-hub",
# "maxConn": 4,
# "mappings": [
# { "pattern": "mc.example.com", "destination": "127.0.0.1:25566", "proxyProtocol": true }
# ]
# }
bin/redapricot-client client.json
```
**3. Connect a player.** Point a DNS record for `mc.example.com` at the hub (or
just add the hub's IP with that hostname), then join `mc.example.com` in
Minecraft. The hub matches the pattern and tunnels you to `127.0.0.1:25566`
behind the client. With `proxyProtocol: true`, the real server sees your true IP
(enable `proxy-protocol` / a compatible front-end on that server to consume it).
## Container image (client)
The Go client ships two ways to build an image.
**Dockerfile** (multi-stage, distroless static, build from the repo root):
```bash
docker build -t redapricot-client .
docker run --rm -v "$PWD/client.json:/etc/redapricot/client.json" \
redapricot-client /etc/redapricot/client.json
```
**[ko](https://ko.build)** (Dockerfile-less; used by CI) builds and pushes
straight from the Go package:
```bash
export KO_DOCKER_REPO=your-registry/namespace/redapricot-client
ko build ./cmd/redapricot-client --bare
```
CI publishes the image on version tags via
`.github/workflows/publish-client-image.yml`. **Before using it, edit the
`KO_DOCKER_REPO` placeholder** at the top of that file to your registry, and (for
a non-ghcr.io registry) set the `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` repo
secrets. The base image and build flags live in `.ko.yaml`.
## Configuration reference
### Hub (`server/config.example.json`)
| Key | Default | Meaning |
|---------------------|------------------|---------|
| `listen` | `0.0.0.0:25565` | Host:port the hub accepts all connections on. |
| `psk` | *(required)* | Shared secret; must match every client. |
| `timestampWindowMs` | `30000` | Allowed clock skew for a client's rekey timestamp. |
| `pendingTimeoutMs` | `10000` | How long a matched player waits for a worker to take over. |
### Client (`client/config.example.json`)
| Key | Default | Meaning |
|------------------|--------------------|---------|
| `server` | *(required)* | Hub `host:port`. |
| `psk` | *(required)* | Shared secret; must match the hub. |
| `maxConn` | `1` (clamped 18) | Max worker connections in the pool. |
| `pingIntervalMs` | `20000` | Control-session keepalive interval. |
| `mappings[]` | *(≥1 required)* | Route table (below). |
| `mappings[].pattern` | — | Hostname players use (matched case-insensitively). |
| `mappings[].destination` | — | Real server `host:port` to forward to. |
| `mappings[].proxyProtocol` | `false` | Prepend a HAProxy v2 header carrying the player's IP. |
## Testing
```bash
./scripts/e2e.sh # build, Go unit tests, then the full e2e suite
```
Or run pieces directly:
```bash
# Go unit tests (codec, crypto, encrypted framing)
go test ./client/... -v
# Java unit tests (VarInt/codec, SHA3-224 vector, key derivation, normalization)
JAVA_HOME=$HOME/.sdkman/candidates/java/current \
gradle -p server test
# End-to-end (spawns the real Java hub + in-process Go client + a mock destination)
go test ./e2e/... -v
```
The e2e suite covers: a full player round-trip with verbatim handshake
forwarding and case-insensitive matching, multi-megabyte transfers, concurrent
streams spreading across multiple worker connections, HAProxy v2 source-address
propagation, player- and destination-initiated disconnect propagation, wrong-PSK
rejection, and dropping of unmatched hostnames. The Go and Java crypto layers are
independently pinned to the same SHA3-224 test vector so they cannot silently
drift apart.
## Design notes & limitations
* **Security is deliberately light.** The PSK proves membership; traffic is
ChaCha20-encrypted (no AEAD tag) to minimize overhead. This protects against
casual sniffing, not a determined active attacker (see the note at the top of
[PROTOCOL.md](PROTOCOL.md) and [docs/architecture.md](docs/architecture.md) §8).
* **No per-stream flow control.** Multiplexing relies on TCP back-pressure per
worker connection, so one very slow stream can head-of-line-block others on
the same connection. Raising `maxConn` spreads load. Fine for interactive
Minecraft traffic; not a general-purpose high-throughput mux.
* **Single hub event loop.** The hub deploys one Vert.x verticle, so all state
is confined to one event loop (no locking). Throughput is bounded by one core;
ample for hundreds of players, not designed for tens of thousands.
## Attribution
The Minecraft protocol reference bundled as `CURRENT_MC_PROTO.txt` is derived
from the [Minecraft Wiki](https://minecraft.wiki/w/Java_Edition_protocol/Packets)
and is licensed under [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/).
+250
View File
@@ -0,0 +1,250 @@
package client
import (
"context"
crand "crypto/rand"
"encoding/binary"
"fmt"
"log"
"net"
"sync"
"time"
"github.com/iceBear67/redapricot/client/wire"
)
// Client is a redapricot client: it holds a control session with the hub and a
// pool of worker connections used to serve player streams.
type Client struct {
cfg *Config
pskBytes []byte
pskAddr string
serverPort uint16
mappings map[string]Mapping // normalized pattern -> mapping
pool *WorkerPool
mu sync.Mutex
ctrl *wire.FramedConn
}
// New builds a client from config.
func New(cfg *Config) *Client {
c := &Client{
cfg: cfg,
pskBytes: []byte(cfg.PSK),
pskAddr: wire.PSKAddress([]byte(cfg.PSK)),
mappings: make(map[string]Mapping),
}
if _, portStr, err := net.SplitHostPort(cfg.Server); err == nil {
if p, err := net.LookupPort("tcp", portStr); err == nil {
c.serverPort = uint16(p)
}
}
for _, m := range cfg.Mappings {
c.mappings[NormalizeAddress(m.Pattern)] = m
}
c.pool = newWorkerPool(c, cfg.MaxConn)
return c
}
// dialSession opens a TCP connection, performs the Intent-17 handshake, the
// Phase-A rekey, and reads SessionReady, returning an established frame conn.
func (c *Client) dialSession(magic byte) (*wire.FramedConn, error) {
conn, err := net.DialTimeout("tcp", c.cfg.Server, 10*time.Second)
if err != nil {
return nil, err
}
if tcp, ok := conn.(*net.TCPConn); ok {
_ = tcp.SetNoDelay(true)
}
ok := false
defer func() {
if !ok {
_ = conn.Close()
}
}()
// 1. plaintext Minecraft Handshake, Intent 17, address = hex(SHA3-224(PSK)).
hs := wire.BuildHandshake(ProtocolVersion, c.pskAddr, c.serverPort, IntentRedapricot)
if _, err := conn.Write(hs); err != nil {
return nil, err
}
// 2. Phase-A ciphers derived from the PSK.
fc := wire.NewFramedConn(conn,
wire.CipherFor(c.pskBytes, wire.DirS2C), // in: server -> client
wire.CipherFor(c.pskBytes, wire.DirC2S), // out: client -> server
)
// 3. Rekey frame (Phase A).
rnd := make([]byte, 16)
if _, err := crand.Read(rnd); err != nil {
return nil, err
}
ts := time.Now().UnixMilli()
rekeyMsg := wire.NewWriter().U8(magic).VarInt(len(rnd)).Bytes(rnd).I64(ts).Out()
if err := fc.WriteFrame(rekeyMsg); err != nil {
return nil, err
}
// 4. Switch to Phase-B ciphers: REKEY = Rand || Timestamp(I64 BE).
rekey := make([]byte, 0, len(rnd)+8)
rekey = append(rekey, rnd...)
var tsb [8]byte
binary.BigEndian.PutUint64(tsb[:], uint64(ts))
rekey = append(rekey, tsb[:]...)
fc.SwitchCiphers(
wire.CipherFor(rekey, wire.DirS2C),
wire.CipherFor(rekey, wire.DirC2S),
)
// 5. SessionReady.
payload, err := fc.ReadFrame()
if err != nil {
return nil, err
}
if len(payload) < 1 || payload[0] != CtlSessionReady {
return nil, fmt.Errorf("expected SessionReady, got %v", payload)
}
ok = true
return fc, nil
}
// Start establishes the control session and registers all patterns. It returns
// once the initial connection succeeds; subsequent drops are handled in the
// background with reconnect.
func (c *Client) Start(ctx context.Context) error {
return c.connectControl(ctx)
}
func (c *Client) connectControl(ctx context.Context) error {
fc, err := c.dialSession(MagicControl)
if err != nil {
return fmt.Errorf("control connect: %w", err)
}
c.registerAll(fc)
c.mu.Lock()
c.ctrl = fc
c.mu.Unlock()
log.Printf("control session established with %s", c.cfg.Server)
go c.serveControl(ctx, fc)
go c.pingLoop(ctx, fc)
return nil
}
func (c *Client) registerAll(fc *wire.FramedConn) {
for pattern := range c.mappings {
msg := wire.NewWriter().U8(CtlRegister).String(pattern).Out()
if err := fc.WriteFrame(msg); err != nil {
log.Printf("register %q: %v", pattern, err)
return
}
log.Printf("registered pattern %q", pattern)
}
}
func (c *Client) serveControl(ctx context.Context, fc *wire.FramedConn) {
for {
payload, err := fc.ReadFrame()
if err != nil {
break
}
c.dispatchControl(payload)
}
_ = fc.Close()
if ctx.Err() != nil {
return
}
// Reconnect with backoff.
for backoff := 500 * time.Millisecond; ctx.Err() == nil; backoff *= 2 {
if backoff > 10*time.Second {
backoff = 10 * time.Second
}
time.Sleep(backoff)
if err := c.connectControl(ctx); err == nil {
return
} else {
log.Printf("control reconnect failed: %v", err)
}
}
}
func (c *Client) dispatchControl(payload []byte) {
r := wire.NewReader(payload)
t, err := r.U8()
if err != nil {
return
}
switch t {
case CtlSessionReady:
// ignore
case CtlRegisterAck:
pattern, _ := r.String()
status, _ := r.U8()
log.Printf("register ack %q status=%d", pattern, status)
case CtlControlRequest:
cid, err := r.Bytes(CIDLen)
if err != nil {
return
}
pattern, _ := r.String()
ip, _ := r.String()
port, _ := r.U16()
go c.handleControlRequest(cid, pattern, ip, int(port))
case CtlPong:
// ignore
default:
log.Printf("control: unknown message type %d", t)
}
}
func (c *Client) pingLoop(ctx context.Context, fc *wire.FramedConn) {
ticker := time.NewTicker(time.Duration(c.cfg.PingIntervalMs) * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
msg := wire.NewWriter().U8(CtlPing).I64(time.Now().UnixMilli()).Out()
if err := fc.WriteFrame(msg); err != nil {
return
}
}
}
}
// handleControlRequest reacts to a matched player: allocate a worker stream,
// SYN it, and bridge it to the mapped destination.
func (c *Client) handleControlRequest(cid []byte, pattern, ip string, port int) {
mapping, ok := c.mappings[NormalizeAddress(pattern)]
if !ok {
log.Printf("control-request for unmapped pattern %q; ignoring", pattern)
return
}
wc, sid, err := c.pool.Allocate()
if err != nil {
log.Printf("worker allocate failed: %v", err)
return
}
st := newStream(wc, sid, cid, mapping, ip, port)
wc.registerStream(sid, st)
wc.sendSyn(sid, cid)
go st.run()
}
// WorkerConnCount reports the current number of open worker connections
// (exposed for tests/observability).
func (c *Client) WorkerConnCount() int { return c.pool.count() }
// Close tears down the control session and all worker connections.
func (c *Client) Close() {
c.mu.Lock()
fc := c.ctrl
c.mu.Unlock()
if fc != nil {
_ = fc.Close()
}
c.pool.closeAll()
}
+13
View File
@@ -0,0 +1,13 @@
{
"server": "hub.example.com:25565",
"psk": "change-me-to-a-long-random-passphrase",
"maxConn": 4,
"pingIntervalMs": 20000,
"mappings": [
{
"pattern": "mc.example.com",
"destination": "127.0.0.1:25566",
"proxyProtocol": true
}
]
}
+94
View File
@@ -0,0 +1,94 @@
package client
import (
"encoding/json"
"fmt"
"os"
"strings"
)
// Protocol constants (mirror of the Java Protocol class; see PROTOCOL.md).
const (
IntentRedapricot = 17
ProtocolVersion = 767 // arbitrary; the hub ignores it
MagicControl = 0x01
MagicWorker = 0x02
CIDLen = 16
CtlSessionReady = 0x00
CtlRegister = 0x01
CtlUnregister = 0x02
CtlRegisterAck = 0x03
CtlControlRequest = 0x04
CtlPing = 0x05
CtlPong = 0x06
MuxSyn = 0x00
MuxData = 0x01
MuxFin = 0x02
MuxRst = 0x03
FrameError = 0x7F
SaturationThreshold = 8
)
// Mapping routes a registered pattern to a real destination.
type Mapping struct {
Pattern string `json:"pattern"`
Destination string `json:"destination"`
ProxyProtocol bool `json:"proxyProtocol"`
}
// Config is the client configuration (PROTOCOL.md §9.2).
type Config struct {
Server string `json:"server"`
PSK string `json:"psk"`
MaxConn int `json:"maxConn"`
PingIntervalMs int `json:"pingIntervalMs"`
Mappings []Mapping `json:"mappings"`
}
// LoadConfig reads and validates a JSON config file.
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var c Config
if err := json.Unmarshal(data, &c); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
if c.Server == "" {
return nil, fmt.Errorf("server is required")
}
if c.PSK == "" {
return nil, fmt.Errorf("psk is required")
}
if c.MaxConn < 1 {
c.MaxConn = 1
}
if c.MaxConn > 8 {
c.MaxConn = 8
}
if c.PingIntervalMs <= 0 {
c.PingIntervalMs = 20000
}
if len(c.Mappings) == 0 {
return nil, fmt.Errorf("at least one mapping is required")
}
return &c, nil
}
// NormalizeAddress matches the hub's normalization: lower-cased, FML-suffix and
// trailing-dot stripped.
func NormalizeAddress(addr string) string {
if i := strings.IndexByte(addr, 0); i >= 0 {
addr = addr[:i]
}
addr = strings.ToLower(addr)
addr = strings.TrimRight(addr, ".")
return addr
}
+43
View File
@@ -0,0 +1,43 @@
package client
import (
"encoding/binary"
"net"
)
// proxyV2Signature is the fixed 12-byte HAProxy v2 signature.
var proxyV2Signature = []byte{
0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A,
}
// BuildProxyV2 builds a HAProxy protocol v2 PROXY header conveying the real
// source (player) and destination addresses (PROTOCOL.md §8).
func BuildProxyV2(srcIP net.IP, srcPort int, dstIP net.IP, dstPort int) []byte {
s4, d4 := srcIP.To4(), dstIP.To4()
out := make([]byte, 0, 16+36)
out = append(out, proxyV2Signature...)
out = append(out, 0x21) // version 2, PROXY command
var addr []byte
if s4 != nil && d4 != nil {
out = append(out, 0x11) // TCP over IPv4
addr = make([]byte, 0, 12)
addr = append(addr, s4...)
addr = append(addr, d4...)
} else {
out = append(out, 0x21) // TCP over IPv6
addr = make([]byte, 0, 36)
addr = append(addr, srcIP.To16()...)
addr = append(addr, dstIP.To16()...)
}
var ports [4]byte
binary.BigEndian.PutUint16(ports[0:], uint16(srcPort))
binary.BigEndian.PutUint16(ports[2:], uint16(dstPort))
addr = append(addr, ports[:]...)
var lenField [2]byte
binary.BigEndian.PutUint16(lenField[:], uint16(len(addr)))
out = append(out, lenField[:]...)
out = append(out, addr...)
return out
}
+44
View File
@@ -0,0 +1,44 @@
package wire
import (
"crypto/sha3"
"encoding/hex"
"golang.org/x/crypto/chacha20"
)
// Direction labels for per-direction key derivation (PROTOCOL.md §3).
const (
DirC2S byte = 0x01 // client -> server
DirS2C byte = 0x02 // server -> client
)
// SHA3_224 returns the SHA3-224 digest of in.
func SHA3_224(in []byte) []byte {
h := sha3.New224()
h.Write(in)
return h.Sum(nil)
}
// PSKAddress is the Handshake Server Address for Intent 17: hex(SHA3-224(PSK)).
func PSKAddress(psk []byte) string {
return hex.EncodeToString(SHA3_224(psk))
}
// DeriveKey computes the 32-byte ChaCha20 key: SHA3-256(phaseKey || dir).
func DeriveKey(phaseKey []byte, dir byte) []byte {
h := sha3.New256()
h.Write(phaseKey)
h.Write([]byte{dir})
return h.Sum(nil)
}
// CipherFor builds a ChaCha20 stream cipher for the given phase key and
// direction. ChaCha20 is symmetric, so the same cipher encrypts and decrypts.
func CipherFor(phaseKey []byte, dir byte) *chacha20.Cipher {
c, err := chacha20.NewUnauthenticatedCipher(DeriveKey(phaseKey, dir), make([]byte, 12))
if err != nil {
panic("wire: chacha20 init: " + err.Error())
}
return c
}
+78
View File
@@ -0,0 +1,78 @@
package wire
import (
"bufio"
"errors"
"io"
"net"
"sync"
"golang.org/x/crypto/chacha20"
)
// MaxFrame is the maximum decrypted frame payload size (1 MiB).
const MaxFrame = 1 << 20
var errFrameTooBig = errors.New("wire: frame exceeds max size")
// FramedConn is the encrypted, length-prefixed frame transport (PROTOCOL.md §3.1).
// The VarInt length prefix is plaintext; the payload is ChaCha20-encrypted with a
// continuous per-direction keystream. Writes are serialized; reads are expected
// from a single goroutine.
type FramedConn struct {
conn net.Conn
r *bufio.Reader
in *chacha20.Cipher
out *chacha20.Cipher
wmu sync.Mutex
}
func NewFramedConn(conn net.Conn, in, out *chacha20.Cipher) *FramedConn {
return &FramedConn{
conn: conn,
r: bufio.NewReader(conn),
in: in,
out: out,
}
}
// SwitchCiphers swaps both ciphers at a frame boundary (Phase A → Phase B).
// Only call this from the same goroutine sequence as reads/writes during the
// handshake, before concurrency begins.
func (f *FramedConn) SwitchCiphers(in, out *chacha20.Cipher) {
f.in = in
f.out = out
}
// ReadFrame reads and decrypts one frame payload.
func (f *FramedConn) ReadFrame() ([]byte, error) {
n, err := ReadVarInt(f.r)
if err != nil {
return nil, err
}
if n < 0 || n > MaxFrame {
return nil, errFrameTooBig
}
ct := make([]byte, n)
if _, err := io.ReadFull(f.r, ct); err != nil {
return nil, err
}
f.in.XORKeyStream(ct, ct) // decrypt in place
return ct, nil
}
// WriteFrame encrypts and sends one frame payload. Safe for concurrent callers.
func (f *FramedConn) WriteFrame(payload []byte) error {
f.wmu.Lock()
defer f.wmu.Unlock()
ct := make([]byte, len(payload))
f.out.XORKeyStream(ct, payload)
out := AppendVarInt(make([]byte, 0, VarIntMaxBytes+len(ct)), len(ct))
out = append(out, ct...)
_, err := f.conn.Write(out)
return err
}
func (f *FramedConn) Close() error { return f.conn.Close() }
func (f *FramedConn) RemoteAddr() net.Addr { return f.conn.RemoteAddr() }
+122
View File
@@ -0,0 +1,122 @@
package wire
import (
"bytes"
"encoding/binary"
"errors"
)
// Writer builds redapricot/Minecraft primitive types into a byte slice.
type Writer struct {
buf []byte
}
func NewWriter() *Writer { return &Writer{} }
func (w *Writer) U8(v byte) *Writer { w.buf = append(w.buf, v); return w }
func (w *Writer) VarInt(v int) *Writer { w.buf = AppendVarInt(w.buf, v); return w }
func (w *Writer) U16(v uint16) *Writer { w.buf = append(w.buf, byte(v>>8), byte(v)); return w }
func (w *Writer) Bytes(p []byte) *Writer { w.buf = append(w.buf, p...); return w }
func (w *Writer) I64(v int64) *Writer {
var b [8]byte
binary.BigEndian.PutUint64(b[:], uint64(v))
w.buf = append(w.buf, b[:]...)
return w
}
func (w *Writer) String(s string) *Writer {
w.VarInt(len(s))
w.buf = append(w.buf, s...)
return w
}
// Out returns the built bytes.
func (w *Writer) Out() []byte { return w.buf }
// Reader consumes redapricot/Minecraft primitive types from a byte slice.
type Reader struct {
buf []byte
pos int
}
func NewReader(b []byte) *Reader { return &Reader{buf: b} }
var errUnderflow = errors.New("wire: read underflow")
func (r *Reader) U8() (byte, error) {
if r.pos >= len(r.buf) {
return 0, errUnderflow
}
v := r.buf[r.pos]
r.pos++
return v, nil
}
func (r *Reader) VarInt() (int, error) {
br := bytes.NewReader(r.buf[r.pos:])
before := br.Len()
v, err := ReadVarInt(br)
if err != nil {
return 0, err
}
r.pos += before - br.Len()
return v, nil
}
func (r *Reader) U16() (uint16, error) {
if r.pos+2 > len(r.buf) {
return 0, errUnderflow
}
v := binary.BigEndian.Uint16(r.buf[r.pos:])
r.pos += 2
return v, nil
}
func (r *Reader) I64() (int64, error) {
if r.pos+8 > len(r.buf) {
return 0, errUnderflow
}
v := int64(binary.BigEndian.Uint64(r.buf[r.pos:]))
r.pos += 8
return v, nil
}
func (r *Reader) Bytes(n int) ([]byte, error) {
if n < 0 || r.pos+n > len(r.buf) {
return nil, errUnderflow
}
out := make([]byte, n)
copy(out, r.buf[r.pos:r.pos+n])
r.pos += n
return out, nil
}
func (r *Reader) String() (string, error) {
n, err := r.VarInt()
if err != nil {
return "", err
}
b, err := r.Bytes(n)
if err != nil {
return "", err
}
return string(b), nil
}
// Remaining returns the unread bytes (a copy is not made).
func (r *Reader) Remaining() []byte { return r.buf[r.pos:] }
// BuildHandshake produces a full uncompressed Minecraft Handshake packet
// (length-prefixed, packet id 0x00).
func BuildHandshake(protocolVersion int, address string, port uint16, intent int) []byte {
body := NewWriter().
VarInt(0x00). // packet id
VarInt(protocolVersion).
String(address).
U16(port).
VarInt(intent).
Out()
out := AppendVarInt(nil, len(body))
return append(out, body...)
}
+51
View File
@@ -0,0 +1,51 @@
package wire
import (
"errors"
"io"
)
// VarIntMaxBytes is the maximum encoded length of a Minecraft VarInt.
const VarIntMaxBytes = 5
var errVarIntTooBig = errors.New("wire: VarInt exceeds 5 bytes")
// ReadVarInt reads a Minecraft-style VarInt from a byte reader.
func ReadVarInt(r io.ByteReader) (int, error) {
var value, shift int
for {
b, err := r.ReadByte()
if err != nil {
return 0, err
}
value |= int(b&0x7F) << shift
if b&0x80 == 0 {
return value, nil
}
shift += 7
if shift >= 32 {
return 0, errVarIntTooBig
}
}
}
// AppendVarInt appends v encoded as a VarInt to dst.
func AppendVarInt(dst []byte, v int) []byte {
u := uint32(v)
for u&^uint32(0x7F) != 0 {
dst = append(dst, byte(u&0x7F)|0x80)
u >>= 7
}
return append(dst, byte(u))
}
// VarIntSize returns the encoded byte length of v.
func VarIntSize(v int) int {
n := 1
u := uint32(v)
for u&^uint32(0x7F) != 0 {
u >>= 7
n++
}
return n
}
+105
View File
@@ -0,0 +1,105 @@
package wire
import (
"bytes"
"net"
"sync"
"testing"
)
// newFramedPair returns two FramedConns wired over an in-memory pipe with
// matching per-direction ciphers (client out=C2S/in=S2C, server the reverse).
func newFramedPair(key []byte) (client, server *FramedConn) {
c, s := net.Pipe()
client = NewFramedConn(c, CipherFor(key, DirS2C), CipherFor(key, DirC2S))
server = NewFramedConn(s, CipherFor(key, DirC2S), CipherFor(key, DirS2C))
return client, server
}
func TestVarIntRoundTrip(t *testing.T) {
cases := []int{0, 1, 127, 128, 255, 300, 16384, 2097151, 1 << 30}
for _, v := range cases {
enc := AppendVarInt(nil, v)
if len(enc) != VarIntSize(v) {
t.Fatalf("size mismatch for %d: got %d want %d", v, len(enc), VarIntSize(v))
}
got, err := ReadVarInt(bytes.NewReader(enc))
if err != nil {
t.Fatalf("read %d: %v", v, err)
}
if got != v {
t.Fatalf("roundtrip %d -> %d", v, got)
}
}
}
// TestPSKAddress cross-validates SHA3-224 against the value the Java hub prints
// for the PSK "test-psk" (locks the two implementations together).
func TestPSKAddress(t *testing.T) {
const want = "90188f2d84e273e4d6fb27194b4a88ad10bcc20de00c493beae6d18f"
if got := PSKAddress([]byte("test-psk")); got != want {
t.Fatalf("PSKAddress = %s, want %s", got, want)
}
}
// TestFramedConnRoundTrip exercises the encrypted framing + keystream continuity
// in both directions over an in-memory pipe.
func TestFramedConnRoundTrip(t *testing.T) {
cli, srv := newFramedPair([]byte("unit-key"))
// Frames of varying sizes to exercise partial-block keystream state.
payloads := [][]byte{
[]byte("a"),
bytes.Repeat([]byte{0xAB}, 63),
bytes.Repeat([]byte{0xCD}, 64),
bytes.Repeat([]byte{0xEF}, 65),
bytes.Repeat([]byte("mux"), 5000),
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for _, p := range payloads {
if err := cli.WriteFrame(p); err != nil {
t.Errorf("client write: %v", err)
return
}
}
}()
for _, want := range payloads {
got, err := srv.ReadFrame()
if err != nil {
t.Fatalf("server read: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatalf("frame mismatch: len(got)=%d len(want)=%d", len(got), len(want))
}
}
wg.Wait()
// Reverse direction.
go func() {
_ = srv.WriteFrame([]byte("pong"))
}()
got, err := cli.ReadFrame()
if err != nil {
t.Fatalf("client read: %v", err)
}
if !bytes.Equal(got, []byte("pong")) {
t.Fatalf("reverse frame mismatch: %q", got)
}
}
func TestProxyBufferShapeIsStable(t *testing.T) {
// A frame with an empty payload must still be a valid (zero-length) frame.
cli, srv := newFramedPair([]byte("k"))
go func() { _ = cli.WriteFrame(nil) }()
got, err := srv.ReadFrame()
if err != nil {
t.Fatalf("read empty frame: %v", err)
}
if len(got) != 0 {
t.Fatalf("expected empty payload, got %d bytes", len(got))
}
}
+324
View File
@@ -0,0 +1,324 @@
package client
import (
"log"
"net"
"sync"
"time"
"github.com/iceBear67/redapricot/client/wire"
)
// WorkerPool manages up to maxConn worker connections and allocates streams
// using the least-loaded strategy (PROTOCOL.md §7.1).
type WorkerPool struct {
client *Client
maxConn int
mu sync.Mutex
conns []*WorkerConn
}
func newWorkerPool(c *Client, maxConn int) *WorkerPool {
return &WorkerPool{client: c, maxConn: maxConn}
}
// Allocate returns a worker conn and a fresh stream id to place a new stream on.
func (p *WorkerPool) Allocate() (*WorkerConn, int, error) {
p.mu.Lock()
defer p.mu.Unlock()
var best *WorkerConn
bestCount := 0
for _, wc := range p.conns {
n := wc.streamCount()
if best == nil || n < bestCount {
best = wc
bestCount = n
}
}
needNew := best == nil || (bestCount > SaturationThreshold && len(p.conns) < p.maxConn)
if needNew {
wc, err := p.dialWorker()
if err != nil {
if best == nil {
return nil, 0, err
}
log.Printf("worker dial failed, reusing existing conn: %v", err)
} else {
p.conns = append(p.conns, wc)
best = wc
}
}
return best, best.newSid(), nil
}
func (p *WorkerPool) dialWorker() (*WorkerConn, error) {
fc, err := p.client.dialSession(MagicWorker)
if err != nil {
return nil, err
}
wc := &WorkerConn{
pool: p,
fc: fc,
streams: make(map[int]*Stream),
nextSid: 1,
}
go wc.readLoop()
log.Printf("opened worker conn (#%d in pool)", len(p.conns)+1)
return wc, nil
}
func (p *WorkerPool) count() int {
p.mu.Lock()
defer p.mu.Unlock()
return len(p.conns)
}
func (p *WorkerPool) remove(wc *WorkerConn) {
p.mu.Lock()
defer p.mu.Unlock()
for i, c := range p.conns {
if c == wc {
p.conns = append(p.conns[:i], p.conns[i+1:]...)
return
}
}
}
func (p *WorkerPool) closeAll() {
p.mu.Lock()
conns := append([]*WorkerConn(nil), p.conns...)
p.mu.Unlock()
for _, wc := range conns {
_ = wc.fc.Close()
}
}
// WorkerConn is one multiplexed worker connection to the hub.
type WorkerConn struct {
pool *WorkerPool
fc *wire.FramedConn
mu sync.Mutex
streams map[int]*Stream
nextSid int
}
func (wc *WorkerConn) streamCount() int {
wc.mu.Lock()
defer wc.mu.Unlock()
return len(wc.streams)
}
func (wc *WorkerConn) newSid() int {
wc.mu.Lock()
defer wc.mu.Unlock()
sid := wc.nextSid
wc.nextSid++
return sid
}
func (wc *WorkerConn) registerStream(sid int, st *Stream) {
wc.mu.Lock()
wc.streams[sid] = st
wc.mu.Unlock()
}
func (wc *WorkerConn) getStream(sid int) *Stream {
wc.mu.Lock()
defer wc.mu.Unlock()
return wc.streams[sid]
}
func (wc *WorkerConn) removeStream(sid int) *Stream {
wc.mu.Lock()
defer wc.mu.Unlock()
st := wc.streams[sid]
delete(wc.streams, sid)
return st
}
func (wc *WorkerConn) readLoop() {
for {
payload, err := wc.fc.ReadFrame()
if err != nil {
break
}
r := wire.NewReader(payload)
ftype, err := r.U8()
if err != nil {
continue
}
sid, err := r.VarInt()
if err != nil {
continue
}
switch ftype {
case MuxData:
if st := wc.getStream(sid); st != nil {
st.deliverFromHub(r.Remaining())
}
case MuxFin, MuxRst:
if st := wc.removeStream(sid); st != nil {
st.shutdown(false)
}
default:
log.Printf("worker: unknown mux type %d", ftype)
}
}
// Connection lost: tear down all streams and drop from pool.
wc.pool.remove(wc)
wc.mu.Lock()
streams := make([]*Stream, 0, len(wc.streams))
for _, st := range wc.streams {
streams = append(streams, st)
}
wc.streams = make(map[int]*Stream)
wc.mu.Unlock()
for _, st := range streams {
st.shutdown(false)
}
}
func (wc *WorkerConn) sendSyn(sid int, cid []byte) {
_ = wc.fc.WriteFrame(wire.NewWriter().U8(MuxSyn).VarInt(sid).Bytes(cid).Out())
}
func (wc *WorkerConn) sendData(sid int, data []byte) error {
return wc.fc.WriteFrame(wire.NewWriter().U8(MuxData).VarInt(sid).Bytes(data).Out())
}
func (wc *WorkerConn) sendFin(sid int) {
_ = wc.fc.WriteFrame(wire.NewWriter().U8(MuxFin).VarInt(sid).Out())
}
func (wc *WorkerConn) sendRst(sid int) {
_ = wc.fc.WriteFrame(wire.NewWriter().U8(MuxRst).VarInt(sid).Out())
}
// Stream bridges one player (via the hub) to one destination connection.
type Stream struct {
wc *WorkerConn
sid int
cid []byte
mapping Mapping
srcIP string
srcPort int
mu sync.Mutex
dest net.Conn
connected bool
preBuf []byte
closed bool
}
func newStream(wc *WorkerConn, sid int, cid []byte, m Mapping, ip string, port int) *Stream {
return &Stream{wc: wc, sid: sid, cid: cid, mapping: m, srcIP: ip, srcPort: port}
}
// run dials the destination, optionally writes the PROXY v2 header, flushes any
// buffered hub bytes, then pumps destination -> hub.
func (s *Stream) run() {
dest, err := net.DialTimeout("tcp", s.mapping.Destination, 10*time.Second)
if err != nil {
log.Printf("stream %d: dial %s failed: %v", s.sid, s.mapping.Destination, err)
s.wc.removeStream(s.sid)
s.wc.sendRst(s.sid)
return
}
if tcp, ok := dest.(*net.TCPConn); ok {
_ = tcp.SetNoDelay(true)
}
if s.mapping.ProxyProtocol {
if hdr := s.buildProxyHeader(dest); hdr != nil {
if _, err := dest.Write(hdr); err != nil {
log.Printf("stream %d: proxy header write: %v", s.sid, err)
}
}
}
// Atomically flush pre-connect buffer and enable direct writes.
s.mu.Lock()
if s.closed {
s.mu.Unlock()
_ = dest.Close()
return
}
s.dest = dest
if len(s.preBuf) > 0 {
_, _ = dest.Write(s.preBuf)
s.preBuf = nil
}
s.connected = true
s.mu.Unlock()
// destination -> hub
buf := make([]byte, 32*1024)
for {
n, err := dest.Read(buf)
if n > 0 {
if werr := s.wc.sendData(s.sid, buf[:n]); werr != nil {
break
}
}
if err != nil {
break
}
}
s.shutdown(true)
}
func (s *Stream) buildProxyHeader(dest net.Conn) []byte {
srcIP := net.ParseIP(s.srcIP)
if srcIP == nil {
return nil
}
dstTCP, ok := dest.RemoteAddr().(*net.TCPAddr)
if !ok {
return nil
}
return BuildProxyV2(srcIP, s.srcPort, dstTCP.IP, dstTCP.Port)
}
// deliverFromHub writes bytes coming from the hub to the destination, buffering
// until the destination connection is established.
func (s *Stream) deliverFromHub(data []byte) {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
if !s.connected {
s.preBuf = append(s.preBuf, data...)
s.mu.Unlock()
return
}
dest := s.dest
s.mu.Unlock()
if _, err := dest.Write(data); err != nil {
s.shutdown(true)
}
}
// shutdown closes the stream; notifyHub sends a FIN to the hub when true.
func (s *Stream) shutdown(notifyHub bool) {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.closed = true
dest := s.dest
s.mu.Unlock()
if dest != nil {
_ = dest.Close()
}
s.wc.removeStream(s.sid)
if notifyHub {
s.wc.sendFin(s.sid)
}
}
+39
View File
@@ -0,0 +1,39 @@
// Command redapricot-client runs the Go client that registers routing patterns
// with a redapricot hub and forwards player traffic to real destinations.
package main
import (
"context"
"flag"
"log"
"os/signal"
"syscall"
"github.com/iceBear67/redapricot/client"
)
func main() {
flag.Parse()
args := flag.Args()
if len(args) < 1 {
log.Fatal("usage: redapricot-client <config.json>")
}
cfg, err := client.LoadConfig(args[0])
if err != nil {
log.Fatalf("config: %v", err)
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
c := client.New(cfg)
if err := c.Start(ctx); err != nil {
log.Fatalf("start: %v", err)
}
log.Printf("redapricot client running; %d mapping(s) registered", len(cfg.Mappings))
<-ctx.Done()
log.Printf("shutting down")
c.Close()
}
+194
View File
@@ -0,0 +1,194 @@
# redapricot architecture
This document explains *how* redapricot is built and *why*. For the exact bytes
on the wire, read [PROTOCOL.md](../PROTOCOL.md).
## 1. Roles and topology
```
┌───────────────────────── public internet ─────────────────────────┐
│ │
┌──────────┐ MC handshake (Intent 2/…) ┌───────────────┐ │
│ Player │ ───────────────────────────────▶│ │ │
└──────────┘ raw Minecraft bytes │ Hub │ │
│ (Java/Vert.x)│ │
┌──────────┐ Intent 17, magic 0x01 │ │ │
│ Client │ ◀──────── control session ──────│ • pattern reg │ │
│ (Go) │ ────────────────────────────────│ • CID table │ │
│ │ Intent 17, magic 0x02 │ • mux demux │ │
│ │ ═════════ worker conns ═════════│ │ │
└──────────┘ multiplexed player streams └───────────────┘ │
│ │
▼ MC bytes (+ optional HAProxy v2) │
┌───────────────┐ │
│ Real MC server│ (behind NAT, next to the client) │
└───────────────┘ │
```
Everything reaches the hub on **one TCP port**. The hub distinguishes three
kinds of inbound connection purely from the first Minecraft **Handshake**:
| Handshake `Intent` | Handled as |
|--------------------|------------|
| `17` + magic `0x01` | a **control session** from a client |
| `17` + magic `0x02` | a **worker connection** from a client |
| `18` | reserved (management/status) — never treated as a player |
| anything else | a **player** to be pattern-matched and tunneled |
Because players use ordinary intents (`1` status, `2` login, `3` transfer),
**vanilla clients need no changes**.
## 2. Connection lifecycle
### 2.1 Client establishes a control session
```
Client Hub
│ TCP connect │
│─ Handshake(Intent=17, addr=hex(SHA3-224(PSK))) ─▶ verify addr == expected
│ │
│ (both derive Phase-A keys = ChaCha20(SHA3-256(PSK ‖ dir)))
│─ Frame#1 [magic=0x01, rand, ts] ──────▶ check |now-ts| ≤ window
│ (both switch to Phase-B keys = ChaCha20(SHA3-256(rand‖ts ‖ dir)))
│◀──────────── Frame [SessionReady] ─────│
│─ Register("mc.example.com") ──────────▶ patterns["mc.example.com"] = session
│◀──────────── RegisterAck ──────────────│
│ ... periodic Ping/Pong ... │
```
Only frame #1 is encrypted with the PSK-derived key; a fresh random `rand‖ts`
becomes the per-connection key for everything after, so two connections never
share a keystream beyond that first frame.
### 2.2 A player arrives and is tunneled
```
Player Hub Client Destination
│─ Handshake(addr="mc.example.com", Intent=2)─▶ normalize+match
│ (+ maybe pipelined Login Start) │ pause player socket,
│ │ buffer bytes, mint CID
│ │─ ControlRequest(CID, pattern, ip:port) ─▶
│ │ allocate worker+stream
│ │◀──────── SYN(streamId, CID) ────────────│
│ │ takePending(CID) → bind dial destination,
│ │ forward buffered bytes write HAProxy v2 hdr
│ │─ DATA(streamId, handshake…) ───▶ ── handshake ──▶│
│ resume ─────────────────────────────│ bridge stream ⇄ dest
│══════════════ player bytes ══ DATA ══▶│════ DATA ═══▶ dest.write │
│◀═══════════ dest bytes ═══ DATA ══════│◀═══ DATA ════ dest.read │
│ player closes ──────────────────────│─ FIN(streamId) ────────▶ close dest │
```
Key points:
* The hub **pauses** the player socket the instant it matches, so no player
bytes are lost while the takeover is arranged; the buffered handshake is
forwarded **verbatim**, so the real server sees exactly what the player sent
(including the original hostname — used for virtual-host routing there).
* **CID** is 16 random bytes minted by the hub and delivered only over the
encrypted control session, so only the intended client learns it. Any worker
connection presenting the correct CID is allowed to take over — that secrecy
is what binds a worker stream to the right pending player without any explicit
client identity.
* Disconnects are symmetric: player-close → hub sends `FIN` → client closes the
destination; destination-close → client sends `FIN` → hub closes the player.
## 3. Multiplexing (worker connections)
A worker connection is one encrypted TCP link carrying many **streams**. The
frame is intentionally tiny (PROTOCOL.md §7):
```
[plaintext VarInt length][ FrameType u8 | StreamID VarInt | Data… ] (payload encrypted)
```
Only the client opens streams (`SYN`), so stream-id allocation is a simple
per-connection counter with no coordination.
### 3.1 Pool & allocation
The client keeps 1…`maxConn` worker connections and places each new stream on
the **least-loaded** one. It opens an additional connection only when the
least-loaded connection is *saturated* (more than 8 active streams) and the pool
is below `maxConn`:
```
pick least-loaded conn
if leastLoaded.streams > 8 and pool.size < maxConn:
dial a new worker conn and use it
else:
use leastLoaded
```
The e2e test `TestConcurrentStreamsUseMultipleConns` drives 20 simultaneous
streams with `maxConn=4` and observes them deterministically spread over 3
connections (9 + 9 + 2), confirming the algorithm.
## 4. Encryption
* **Cipher:** ChaCha20 (RFC 8439) as a raw stream cipher over frame *payloads*.
The length prefix is plaintext, which makes the cipher **phase switch** at
rekey trivial (a reader always knows exactly how many ciphertext bytes belong
to the current frame and never decrypts the next frame with the wrong key).
* **Keys:** `SHA3-256(phaseKey ‖ 0x01)` for client→server and
`SHA3-256(phaseKey ‖ 0x02)` for server→client. Distinct per-direction keys
with a fixed zero nonce avoid a two-time pad without nonce management.
* **Interop:** Java's JCE `ChaCha20` and Go's `x/crypto/chacha20` produce byte-
identical keystreams (including across partial-block, arbitrarily-split
writes), and both `crypto/sha3` implementations agree — verified directly and
pinned by unit tests on both sides against a shared SHA3-224 vector.
## 5. Threading model
* **Hub:** a single Vert.x verticle instance. All accepted connections are
handled on that verticle's one event loop, so the pattern registry, CID table,
and per-connection state are touched by a single thread — no locks on the hot
path (concurrent maps are used only defensively). Every socket operation is
non-blocking; crypto is CPU-cheap. This trades multi-core scaling for
simplicity and correctness.
* **Client:** goroutine-per-concern. One goroutine reads each connection
(control or worker); `WriteFrame` is mutex-serialized so many stream goroutines
can share a worker connection safely. A per-stream mutex guards the small
"buffer until the destination is connected, then write directly" handoff so
the forwarded handshake never races ahead of later bytes.
## 6. Back-pressure
There is no per-stream credit window. Flow is governed by TCP back-pressure on
each worker connection:
* Hub → player: if a player socket's write queue fills, the hub pauses the
worker connection socket and resumes on drain.
* Destination → hub: the client's `WriteFrame` blocks when the worker socket is
congested, which naturally stops the client reading the destination.
The consequence is head-of-line blocking *within* a worker connection: one very
slow player can stall other streams sharing that connection. `maxConn` spreads
streams across connections to mitigate this. For interactive Minecraft traffic
(small client→server packets, bursty server→client chunk data) this is a good
trade for a near-zero-overhead mux.
## 7. Failure & recovery
* **Control session drop:** the client reconnects with capped exponential
backoff and re-registers all patterns. Existing worker connections and their
live streams are unaffected.
* **Worker connection drop:** every stream on it is torn down (destinations
closed); the hub closes the corresponding player sockets; the client removes
the connection from the pool and will dial a fresh one on the next allocation.
* **Pending timeout:** if no worker takes over a matched player within
`pendingTimeoutMs`, the hub drops the pending entry and closes the player.
* **Bad PSK / bad timestamp / bad magic:** the hub closes the TCP connection;
the client's session establishment fails fast.
## 8. Known limitations
1. No AEAD — payload integrity/authenticity is not cryptographically guaranteed.
2. No per-stream flow control (see §6).
3. Single-event-loop hub (see §5) bounds throughput to one core.
4. `Intent 18` is reserved but only stubbed (the hub logs and closes).
5. Pattern ownership is last-writer-wins; two clients registering the same
hostname will silently reassign it.
These are deliberate scope choices for a connectivity-focused P2P tool, not
oversights; each is a small, well-isolated change away from being hardened.
+5
View File
@@ -0,0 +1,5 @@
// Package e2e contains end-to-end integration tests that exercise the full
// redapricot data path: a real Java hub subprocess, the in-process Go client,
// a mock Minecraft destination, and simulated players. The tests live in
// *_test.go files; this file exists so `go build ./...` has a buildable package.
package e2e
+265
View File
@@ -0,0 +1,265 @@
package e2e
import (
"context"
"fmt"
"io"
"net"
"testing"
"time"
"github.com/iceBear67/redapricot/client"
)
// startClient builds and starts an in-process client against the hub, with the
// given mappings, returning the running client.
func startClient(t *testing.T, hubAddr, psk string, maxConn int, mappings []client.Mapping) *client.Client {
t.Helper()
cfg := &client.Config{
Server: hubAddr,
PSK: psk,
MaxConn: maxConn,
PingIntervalMs: 20000,
Mappings: mappings,
}
c := client.New(cfg)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(func() {
cancel()
c.Close()
})
if err := c.Start(ctx); err != nil {
t.Fatalf("client start: %v", err)
}
// Give the control-session registration a moment to reach the hub.
time.Sleep(200 * time.Millisecond)
return c
}
// TestRoundTrip verifies the full path plus verbatim handshake forwarding and
// case-insensitive pattern matching.
func TestRoundTrip(t *testing.T) {
const psk = "e2e-roundtrip"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, 4, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
// The player connects using mixed case; the hub matches case-insensitively
// but forwards the address verbatim.
pc := dialPlayer(t, hubAddr, "MC.Local")
defer pc.Close()
playerEcho(t, pc, []byte("hello redapricot"))
ev := dest.waitEvent(t, 5*time.Second)
if ev.handshakeAddr != "MC.Local" {
t.Fatalf("destination saw handshake address %q, want verbatim %q", ev.handshakeAddr, "MC.Local")
}
if ev.hasProxy {
t.Fatalf("did not expect a PROXY header for a non-proxy mapping")
}
}
// TestLargeTransfer pushes a multi-megabyte payload both ways to exercise mux
// framing and back-pressure.
func TestLargeTransfer(t *testing.T) {
const psk = "e2e-large"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, 2, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
pc := dialPlayer(t, hubAddr, "mc.local")
defer pc.Close()
payload := make([]byte, 3*1024*1024)
for i := range payload {
payload[i] = byte(i*31 + 7)
}
// Write from a goroutine while reading back concurrently to avoid deadlock.
writeErr := make(chan error, 1)
go func() {
_, err := pc.Write(payload)
writeErr <- err
}()
got := make([]byte, len(payload))
_ = pc.SetReadDeadline(time.Now().Add(30 * time.Second))
if _, err := io.ReadFull(pc, got); err != nil {
t.Fatalf("read large echo: %v", err)
}
if err := <-writeErr; err != nil {
t.Fatalf("write large payload: %v", err)
}
for i := range payload {
if got[i] != payload[i] {
t.Fatalf("large echo mismatch at byte %d", i)
}
}
}
// TestConcurrentStreamsUseMultipleConns confirms the least-loaded allocator
// opens additional worker connections once streams saturate (>8).
func TestConcurrentStreamsUseMultipleConns(t *testing.T) {
const psk = "e2e-concurrent"
const n = 20
const maxConn = 4
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
c := startClient(t, hubAddr, psk, maxConn, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
conns := make([]net.Conn, 0, n)
defer func() {
for _, pc := range conns {
_ = pc.Close()
}
}()
// Establish streams sequentially so allocation is deterministic; keep them
// all open to hold streams active.
for i := 0; i < n; i++ {
pc := dialPlayer(t, hubAddr, "mc.local")
playerEcho(t, pc, []byte(fmt.Sprintf("hello-%d", i)))
conns = append(conns, pc)
}
got := c.WorkerConnCount()
if got < 2 {
t.Fatalf("expected >=2 worker conns for %d concurrent streams, got %d", n, got)
}
if got > maxConn {
t.Fatalf("worker conns %d exceed maxConn %d", got, maxConn)
}
t.Logf("%d concurrent streams spread over %d worker conn(s)", n, got)
}
// TestProxyProtocol checks that the client prepends a correct HAProxy v2 header
// carrying the player's real source address.
func TestProxyProtocol(t *testing.T) {
const psk = "e2e-proxy"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, 2, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr, ProxyProtocol: true},
})
pc := dialPlayer(t, hubAddr, "mc.local")
defer pc.Close()
playerEcho(t, pc, []byte("proxied hello"))
ev := dest.waitEvent(t, 5*time.Second)
if !ev.hasProxy {
t.Fatalf("expected a PROXY v2 header")
}
localPort := pc.LocalAddr().(*net.TCPAddr).Port
if ev.proxy.srcPort != localPort {
t.Fatalf("proxy src port %d, want player local port %d", ev.proxy.srcPort, localPort)
}
if !ev.proxy.srcIP.IsLoopback() {
t.Fatalf("proxy src ip %v, want loopback", ev.proxy.srcIP)
}
t.Logf("PROXY v2: src=%v:%d dst=%v:%d", ev.proxy.srcIP, ev.proxy.srcPort, ev.proxy.dstIP, ev.proxy.dstPort)
}
// TestPlayerDisconnectPropagates: player closing → hub FIN → destination EOF.
func TestPlayerDisconnectPropagates(t *testing.T) {
const psk = "e2e-disc"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, 2, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
pc := dialPlayer(t, hubAddr, "mc.local")
playerEcho(t, pc, []byte("bye soon"))
_ = pc.Close()
select {
case <-dest.connClosed:
case <-time.After(5 * time.Second):
t.Fatalf("destination did not observe the player disconnect")
}
}
// TestDestinationDisconnectPropagates: destination closing → client FIN → player EOF.
func TestDestinationDisconnectPropagates(t *testing.T) {
const psk = "e2e-destclose"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEchoOnceClose)
startClient(t, hubAddr, psk, 2, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
pc := dialPlayer(t, hubAddr, "mc.local")
defer pc.Close()
payload := []byte("one shot")
if _, err := pc.Write(payload); err != nil {
t.Fatalf("write: %v", err)
}
got := make([]byte, len(payload))
_ = pc.SetReadDeadline(time.Now().Add(5 * time.Second))
if _, err := io.ReadFull(pc, got); err != nil {
t.Fatalf("read echo: %v", err)
}
// Destination has now closed; the player's next read must reach EOF.
_ = pc.SetReadDeadline(time.Now().Add(5 * time.Second))
if _, err := pc.Read(make([]byte, 16)); err == nil {
t.Fatalf("expected EOF after destination closed")
}
}
// TestBadPSK: a client with the wrong PSK cannot establish a control session.
func TestBadPSK(t *testing.T) {
const psk = "e2e-correct"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
cfg := &client.Config{
Server: hubAddr,
PSK: "totally-wrong",
MaxConn: 2,
PingIntervalMs: 20000,
Mappings: []client.Mapping{{Pattern: "mc.local", Destination: dest.addr}},
}
c := client.New(cfg)
ctx, cancel := context.WithCancel(context.Background())
defer func() { cancel(); c.Close() }()
if err := c.Start(ctx); err == nil {
t.Fatalf("expected control session to fail with a wrong PSK")
}
}
// TestUnmatchedPattern: a player using an unregistered address is dropped.
func TestUnmatchedPattern(t *testing.T) {
const psk = "e2e-nomatch"
port := freePort(t)
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
startHub(t, port, psk)
dest := newMockDest(t, modeEcho)
startClient(t, hubAddr, psk, 2, []client.Mapping{
{Pattern: "mc.local", Destination: dest.addr},
})
pc := dialPlayer(t, hubAddr, "unknown.host")
defer pc.Close()
_ = pc.SetReadDeadline(time.Now().Add(5 * time.Second))
if _, err := pc.Read(make([]byte, 16)); err == nil {
t.Fatalf("expected the hub to drop an unmatched player")
}
}
+304
View File
@@ -0,0 +1,304 @@
package e2e
import (
"bufio"
"bytes"
"encoding/binary"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"sync"
"testing"
"time"
"github.com/iceBear67/redapricot/client/wire"
)
// ---- repo / toolchain discovery ----
func repoRoot() string {
_, file, _, _ := runtime.Caller(0)
return filepath.Dir(filepath.Dir(file)) // e2e/ -> repo root
}
func resolveJava(t *testing.T) string {
t.Helper()
if jh := os.Getenv("JAVA_HOME"); jh != "" {
p := filepath.Join(jh, "bin", "java")
if _, err := os.Stat(p); err == nil {
return p
}
}
if home, err := os.UserHomeDir(); err == nil {
p := filepath.Join(home, ".sdkman/candidates/java/current/bin/java")
if _, err := os.Stat(p); err == nil {
return p
}
}
if p, err := exec.LookPath("java"); err == nil {
return p
}
t.Skip("java not found (set JAVA_HOME)")
return ""
}
// ---- hub subprocess ----
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("free port: %v", err)
}
defer ln.Close()
return ln.Addr().(*net.TCPAddr).Port
}
// startHub launches the Java hub on the given port and blocks until it accepts
// connections. The process is killed on test cleanup.
func startHub(t *testing.T, port int, psk string) {
t.Helper()
install := filepath.Join(repoRoot(), "server", "build", "install", "redapricot-server")
if _, err := os.Stat(install); err != nil {
t.Fatalf("hub not built at %s (run scripts/build.sh first): %v", install, err)
}
cfg := fmt.Sprintf(`{"listen":"127.0.0.1:%d","psk":%q,"timestampWindowMs":30000,"pendingTimeoutMs":5000}`, port, psk)
cfgPath := filepath.Join(t.TempDir(), "hub.json")
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
t.Fatal(err)
}
java := resolveJava(t)
cp := filepath.Join(install, "lib", "*")
cmd := exec.Command(java, "-cp", cp, "io.icybear.redapricot.Main", cfgPath)
cmd.Stdout = &prefixWriter{prefix: "[hub] "}
cmd.Stderr = cmd.Stdout
if err := cmd.Start(); err != nil {
t.Fatalf("start hub: %v", err)
}
t.Cleanup(func() {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
})
waitPort(t, fmt.Sprintf("127.0.0.1:%d", port), 30*time.Second)
}
func waitPort(t *testing.T, addr string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
c, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
if err == nil {
_ = c.Close()
return
}
time.Sleep(100 * time.Millisecond)
}
t.Fatalf("hub did not come up on %s within %s", addr, timeout)
}
type prefixWriter struct {
prefix string
mu sync.Mutex
buf []byte
}
func (w *prefixWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
w.buf = append(w.buf, p...)
for {
i := bytes.IndexByte(w.buf, '\n')
if i < 0 {
break
}
fmt.Fprintf(os.Stderr, "%s%s\n", w.prefix, w.buf[:i])
w.buf = w.buf[i+1:]
}
return len(p), nil
}
// ---- mock Minecraft destination ----
type destMode int
const (
modeEcho destMode = iota // echo every post-handshake byte
modeEchoOnceClose // echo one read, then close the connection
)
type proxyInfo struct {
srcIP net.IP
srcPort int
dstIP net.IP
dstPort int
}
type destEvent struct {
handshakeAddr string
hasProxy bool
proxy proxyInfo
}
var proxyV2Signature = []byte{
0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A,
}
type mockDest struct {
ln net.Listener
addr string
mode destMode
events chan destEvent
connClosed chan struct{}
}
func newMockDest(t *testing.T, mode destMode) *mockDest {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("mock dest listen: %v", err)
}
d := &mockDest{
ln: ln,
addr: ln.Addr().String(),
mode: mode,
events: make(chan destEvent, 128),
connClosed: make(chan struct{}, 128),
}
t.Cleanup(func() { _ = ln.Close() })
go d.serve()
return d
}
func (d *mockDest) serve() {
for {
conn, err := d.ln.Accept()
if err != nil {
return
}
go d.handle(conn)
}
}
func (d *mockDest) handle(conn net.Conn) {
defer func() {
_ = conn.Close()
d.connClosed <- struct{}{}
}()
br := bufio.NewReader(conn)
var ev destEvent
if sig, err := br.Peek(12); err == nil && bytes.Equal(sig, proxyV2Signature) {
hdr := make([]byte, 16)
if _, err := io.ReadFull(br, hdr); err != nil {
return
}
famProto := hdr[13]
addrLen := int(binary.BigEndian.Uint16(hdr[14:16]))
block := make([]byte, addrLen)
if _, err := io.ReadFull(br, block); err != nil {
return
}
ev.hasProxy = true
ev.proxy = parseProxyAddr(famProto, block)
}
// Minecraft handshake packet.
pktLen, err := wire.ReadVarInt(br)
if err != nil {
return
}
pkt := make([]byte, pktLen)
if _, err := io.ReadFull(br, pkt); err != nil {
return
}
rr := wire.NewReader(pkt)
_, _ = rr.VarInt() // packet id
_, _ = rr.VarInt() // protocol version
addr, _ := rr.String()
ev.handshakeAddr = addr
d.events <- ev
switch d.mode {
case modeEcho:
_, _ = io.Copy(conn, br) // echo until the peer closes
case modeEchoOnceClose:
buf := make([]byte, 4096)
n, _ := br.Read(buf)
if n > 0 {
_, _ = conn.Write(buf[:n])
}
// fallthrough to close via defer
}
}
func (d *mockDest) waitEvent(t *testing.T, timeout time.Duration) destEvent {
t.Helper()
select {
case ev := <-d.events:
return ev
case <-time.After(timeout):
t.Fatalf("destination received no connection within %s", timeout)
return destEvent{}
}
}
func parseProxyAddr(famProto byte, block []byte) proxyInfo {
var pi proxyInfo
switch famProto {
case 0x11: // TCP/IPv4
if len(block) >= 12 {
pi.srcIP = net.IP(block[0:4])
pi.dstIP = net.IP(block[4:8])
pi.srcPort = int(binary.BigEndian.Uint16(block[8:10]))
pi.dstPort = int(binary.BigEndian.Uint16(block[10:12]))
}
case 0x21: // TCP/IPv6
if len(block) >= 36 {
pi.srcIP = net.IP(block[0:16])
pi.dstIP = net.IP(block[16:32])
pi.srcPort = int(binary.BigEndian.Uint16(block[32:34]))
pi.dstPort = int(binary.BigEndian.Uint16(block[34:36]))
}
}
return pi
}
// ---- player simulator ----
// dialPlayer connects to the hub and sends a Minecraft Handshake (login intent)
// with the given server address, returning the open connection.
func dialPlayer(t *testing.T, hubAddr, address string) net.Conn {
t.Helper()
conn, err := net.DialTimeout("tcp", hubAddr, 5*time.Second)
if err != nil {
t.Fatalf("player dial: %v", err)
}
hs := wire.BuildHandshake(767, address, 25565, 2) // intent 2 = login
if _, err := conn.Write(hs); err != nil {
t.Fatalf("player handshake: %v", err)
}
return conn
}
// playerEcho sends payload and asserts the same bytes come back (proving the
// full player↔destination round-trip works).
func playerEcho(t *testing.T, conn net.Conn, payload []byte) {
t.Helper()
if _, err := conn.Write(payload); err != nil {
t.Fatalf("player write: %v", err)
}
got := make([]byte, len(payload))
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
if _, err := io.ReadFull(conn, got); err != nil {
t.Fatalf("player read echo: %v", err)
}
_ = conn.SetReadDeadline(time.Time{})
if !bytes.Equal(got, payload) {
t.Fatalf("echo mismatch: sent %q got %q", payload, got)
}
}
+7
View File
@@ -0,0 +1,7 @@
module github.com/iceBear67/redapricot
go 1.25.0
require golang.org/x/crypto v0.52.0
require golang.org/x/sys v0.46.0 // indirect
+4
View File
@@ -0,0 +1,4 @@
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Build the redapricot hub (Java) and client (Go).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# The Gradle JVM: prefer JAVA_HOME, then an SDKMAN JDK, then whatever is on PATH.
if [[ -z "${JAVA_HOME:-}" ]]; then
if [[ -d "$HOME/.sdkman/candidates/java/current" ]]; then
JAVA_HOME="$HOME/.sdkman/candidates/java/current"
fi
fi
export JAVA_HOME
GRADLE="${GRADLE:-$HOME/.sdkman/candidates/gradle/current/bin/gradle}"
if ! command -v "$GRADLE" >/dev/null 2>&1; then
GRADLE="gradle"
fi
echo ">> Building Java hub server (installDist)..."
"$GRADLE" -p "$ROOT/server" installDist --console=plain
echo ">> Building Go client..."
mkdir -p "$ROOT/bin"
( cd "$ROOT" && go build -o "$ROOT/bin/redapricot-client" ./cmd/redapricot-client )
cat <<EOF
>> Build complete.
Hub: $ROOT/server/build/install/redapricot-server/bin/redapricot-server <config.json>
Client: $ROOT/bin/redapricot-client <config.json>
EOF
Executable
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Build the hub, then run the end-to-end integration suite.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# The Java hub must be installed for the e2e harness to launch it.
"$ROOT/scripts/build.sh"
echo ">> Running Go unit tests..."
( cd "$ROOT" && go test ./client/... -count=1 )
echo ">> Running end-to-end tests..."
( cd "$ROOT" && go test ./e2e/... -count=1 -v -timeout 300s )
+55
View File
@@ -0,0 +1,55 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
plugins {
java
application
id("com.gradleup.shadow") version "9.5.1"
id("io.freefair.lombok") version "9.5.0"
}
group = "io.icybear.redapricot"
version = "0.1.0"
repositories {
mavenCentral()
}
dependencies {
implementation("io.vertx:vertx-core:5.1.5")
testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.10.2")
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
application {
mainClass = "io.icybear.redapricot.Main"
}
tasks.test {
useJUnitPlatform()
}
// Fat "shadow" jar: build/libs/redapricot-server-<version>-all.jar
// java -jar build/libs/redapricot-server-0.1.0-all.jar <config.json>
// mergeServiceFiles() is required so Vert.x/Netty SPI (META-INF/services/*)
// survives the relocation into a single jar.
tasks.named<ShadowJar>("shadowJar") {
archiveClassifier.set("all")
mergeServiceFiles()
manifest {
attributes["Main-Class"] = "io.icybear.redapricot.Main"
}
}
// Build the shadow jar as part of the default `build`/`assemble` lifecycle.
tasks.named("assemble") {
dependsOn("shadowJar")
}
// Make `installDist` output predictable for the e2e harness: it produces
// build/install/redapricot-server/lib/*.jar + a start script.
+6
View File
@@ -0,0 +1,6 @@
{
"listen": "0.0.0.0:25565",
"psk": "change-me-to-a-long-random-passphrase",
"timestampWindowMs": 30000,
"pendingTimeoutMs": 10000
}
+1
View File
@@ -0,0 +1 @@
rootProject.name = "redapricot-server"
@@ -0,0 +1,48 @@
package io.icybear.redapricot;
import io.icybear.redapricot.util.Json;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
/** Hub configuration, loaded from a JSON file. See PROTOCOL.md §9.1. */
public final class Config {
public final String host;
public final int port;
public final String psk;
public final long timestampWindowMs;
public final long pendingTimeoutMs;
public Config(String host, int port, String psk, long timestampWindowMs, long pendingTimeoutMs) {
this.host = host;
this.port = port;
this.psk = psk;
this.timestampWindowMs = timestampWindowMs;
this.pendingTimeoutMs = pendingTimeoutMs;
}
public static Config load(Path file) throws Exception {
Map<String, Object> m = Json.parseObject(Files.readString(file));
String listen = str(m, "listen", "0.0.0.0:25565");
int idx = listen.lastIndexOf(':');
if (idx < 0) throw new IllegalArgumentException("listen must be host:port");
String host = listen.substring(0, idx);
int port = Integer.parseInt(listen.substring(idx + 1));
String psk = str(m, "psk", null);
if (psk == null || psk.isEmpty()) throw new IllegalArgumentException("psk is required");
long tsWin = num(m, "timestampWindowMs", 30000);
long pending = num(m, "pendingTimeoutMs", 10000);
return new Config(host, port, psk, tsWin, pending);
}
private static String str(Map<String, Object> m, String k, String def) {
Object v = m.get(k);
return v == null ? def : v.toString();
}
private static long num(Map<String, Object> m, String k, long def) {
Object v = m.get(k);
return v == null ? def : (long) ((Number) v).doubleValue();
}
}
@@ -0,0 +1,79 @@
package io.icybear.redapricot;
import io.icybear.redapricot.net.EncryptedFrames;
import io.icybear.redapricot.util.ProtoReader;
import io.icybear.redapricot.util.ProtoWriter;
/**
* An authenticated control session (Magic 0x01). Carries pattern registrations
* and control requests; never tunnels game data.
*/
public final class ControlSession {
private static final System.Logger LOG = System.getLogger("redapricot.control");
private final Hub hub;
private final EncryptedFrames frames;
private final String id;
public ControlSession(Hub hub, EncryptedFrames frames, String id) {
this.hub = hub;
this.frames = frames;
this.id = id;
}
public String id() { return id; }
public void onFrame(byte[] payload) {
ProtoReader r = new ProtoReader(payload);
int type = r.readUByte();
switch (type) {
case Protocol.CTL_REGISTER -> {
String pattern = r.readString();
hub.register(pattern, this);
sendRegisterAck(pattern, 0);
}
case Protocol.CTL_UNREGISTER -> {
String pattern = r.readString();
hub.unregister(pattern, this);
}
case Protocol.CTL_PING -> {
long nonce = r.readI64();
sendPong(nonce);
}
case Protocol.FRAME_ERROR -> LOG.log(System.Logger.Level.WARNING,
"control " + id + " error: " + r.readString());
default -> LOG.log(System.Logger.Level.WARNING,
"control " + id + " unknown message type " + type);
}
}
public void sendControlRequest(byte[] cid, String pattern, String playerIp, int playerPort) {
byte[] msg = new ProtoWriter()
.u8(Protocol.CTL_CONTROL_REQUEST)
.bytes(cid)
.string(pattern)
.string(playerIp)
.u16(playerPort)
.toBytes();
frames.send(msg);
LOG.log(System.Logger.Level.INFO,
"control-request pattern=" + pattern + " player=" + playerIp + ":" + playerPort);
}
private void sendRegisterAck(String pattern, int status) {
frames.send(new ProtoWriter()
.u8(Protocol.CTL_REGISTER_ACK)
.string(pattern)
.u8(status)
.toBytes());
}
private void sendPong(long nonce) {
frames.send(new ProtoWriter().u8(Protocol.CTL_PONG).i64(nonce).toBytes());
}
public void onClose() {
hub.removeSession(this);
LOG.log(System.Logger.Level.INFO, "control session " + id + " closed");
}
}
@@ -0,0 +1,100 @@
package io.icybear.redapricot;
import io.icybear.redapricot.crypto.Crypto;
import io.icybear.redapricot.util.Hex;
import io.vertx.core.Vertx;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadLocalRandom;
/**
* Shared hub state: the pattern registry and the pending-player table. A single
* verticle instance owns this, so access is confined to one event loop; the
* concurrent maps are defensive.
*/
public final class Hub {
private static final System.Logger LOG = System.getLogger("redapricot.hub");
public final Vertx vertx;
public final Config config;
public final byte[] pskBytes;
public final String pskAddress;
private final Map<String, ControlSession> patterns = new ConcurrentHashMap<>();
private final Map<String, PendingPlayer> pending = new ConcurrentHashMap<>();
public Hub(Vertx vertx, Config config) {
this.vertx = vertx;
this.config = config;
this.pskBytes = config.psk.getBytes(StandardCharsets.UTF_8);
this.pskAddress = Crypto.pskAddress(config.psk);
}
// ---- pattern registry ----
public void register(String pattern, ControlSession session) {
String key = normalizeAddress(pattern);
patterns.put(key, session);
LOG.log(System.Logger.Level.INFO, "registered pattern '" + key + "' -> " + session.id());
}
public void unregister(String pattern, ControlSession session) {
String key = normalizeAddress(pattern);
patterns.remove(key, session);
}
public ControlSession match(String address) {
return patterns.get(normalizeAddress(address));
}
/** Drop every pattern currently owned by a (closing) session. */
public void removeSession(ControlSession session) {
patterns.entrySet().removeIf(e -> e.getValue() == session);
}
// ---- pending players ----
public byte[] newCid() {
byte[] cid = new byte[Protocol.CID_LEN];
ThreadLocalRandom.current().nextBytes(cid);
return cid;
}
public void addPending(PendingPlayer p) {
pending.put(p.cidHex, p);
p.timerId = vertx.setTimer(config.pendingTimeoutMs, id -> {
PendingPlayer removed = pending.remove(p.cidHex);
if (removed != null) {
LOG.log(System.Logger.Level.WARNING, "pending player " + p.cidHex + " timed out");
removed.socket.close();
}
});
}
public PendingPlayer takePending(byte[] cid) {
String hex = Hex.encode(cid);
PendingPlayer p = pending.remove(hex);
if (p != null && p.timerId >= 0) vertx.cancelTimer(p.timerId);
return p;
}
public void removePending(String cidHex) {
PendingPlayer p = pending.remove(cidHex);
if (p != null && p.timerId >= 0) vertx.cancelTimer(p.timerId);
}
// ---- helpers ----
/** Lower-cased, FML-suffix-stripped, trailing-dot-stripped hostname. */
public static String normalizeAddress(String addr) {
int nul = addr.indexOf('\0');
if (nul >= 0) addr = addr.substring(0, nul);
addr = addr.toLowerCase(Locale.ROOT);
while (addr.endsWith(".")) addr = addr.substring(0, addr.length() - 1);
return addr;
}
}
@@ -0,0 +1,199 @@
package io.icybear.redapricot;
import io.icybear.redapricot.crypto.Crypto;
import io.icybear.redapricot.net.EncryptedFrames;
import io.icybear.redapricot.util.Hex;
import io.icybear.redapricot.util.ProtoReader;
import io.icybear.redapricot.util.VarInt;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.net.NetSocket;
import java.util.concurrent.atomic.AtomicLong;
/**
* Per-socket state machine: reads the initial Minecraft Handshake, then either
* establishes an encrypted redapricot session (control / worker) or routes the
* connection as a player. See PROTOCOL.md §2 and §4.
*/
public final class HubConnection {
private static final System.Logger LOG = System.getLogger("redapricot.conn");
private static final AtomicLong SEQ = new AtomicLong();
private static final int MAX_HANDSHAKE = 8192;
private final Hub hub;
private final NetSocket socket;
private final String id;
private Buffer hs = Buffer.buffer();
private boolean dispatched = false;
private Runnable closeCleanup = () -> {};
// encryption / session state
private EncryptedFrames frames;
public HubConnection(Hub hub, NetSocket socket) {
this.hub = hub;
this.socket = socket;
this.id = "#" + SEQ.incrementAndGet();
}
public void start() {
socket.handler(this::onRaw);
socket.closeHandler(v -> closeCleanup.run());
socket.exceptionHandler(t -> socket.close());
}
private void onRaw(Buffer b) {
if (dispatched) return;
hs.appendBuffer(b);
if (hs.length() > MAX_HANDSHAKE) {
LOG.log(System.Logger.Level.WARNING, id + " handshake too large; closing");
socket.close();
return;
}
tryParseHandshake();
}
private void tryParseHandshake() {
VarInt.Read lr;
try {
lr = VarInt.tryRead(hs, 0);
} catch (RuntimeException e) {
socket.close();
return;
}
if (lr == null) return;
int pktLen = lr.value();
int hdr = lr.size();
if (pktLen < 0 || pktLen > Protocol.MAX_FRAME) {
socket.close();
return;
}
if (hs.length() < hdr + pktLen) return; // wait for the full packet
byte[] packet = hs.getBytes(hdr, hdr + pktLen);
Buffer afterHandshake = hs.getBuffer(hdr + pktLen, hs.length());
dispatched = true;
try {
dispatch(packet, afterHandshake);
} catch (RuntimeException e) {
LOG.log(System.Logger.Level.WARNING, id + " handshake error: " + e);
socket.close();
}
}
private void dispatch(byte[] packet, Buffer afterHandshake) {
ProtoReader r = new ProtoReader(packet);
int packetId = r.readVarInt();
if (packetId != 0x00) {
socket.close();
return;
}
r.readVarInt(); // protocol version (ignored)
String address = r.readString();
int port = r.readU16(); // server port (ignored)
int intent = r.readVarInt();
if (intent == Protocol.INTENT_REDAPRICOT) {
beginRedapricot(address, afterHandshake);
} else if (intent == Protocol.INTENT_RESERVED) {
LOG.log(System.Logger.Level.INFO, id + " reserved intent 18; closing");
socket.close();
} else {
handlePlayer(address);
}
}
// ---- redapricot session (Intent 17) ----
private void beginRedapricot(String address, Buffer afterHandshake) {
if (!address.equalsIgnoreCase(hub.pskAddress)) {
LOG.log(System.Logger.Level.WARNING, id + " bad PSK address; closing");
socket.close();
return;
}
// Phase A ciphers derived from the configured PSK.
frames = new EncryptedFrames(
socket,
Crypto.decryptCipher(hub.pskBytes, Crypto.DIR_C2S),
Crypto.encryptCipher(hub.pskBytes, Crypto.DIR_S2C),
this::onRekeyFrame);
socket.handler(frames::feed);
frames.feed(afterHandshake);
}
private void onRekeyFrame(byte[] payload) {
ProtoReader r = new ProtoReader(payload);
int magic = r.readUByte();
int randLen = r.readVarInt();
if (randLen < 8 || randLen > 64) {
LOG.log(System.Logger.Level.WARNING, id + " bad rekey randLen; closing");
frames.close();
return;
}
byte[] rand = r.readBytes(randLen);
long ts = r.readI64();
long now = System.currentTimeMillis();
if (Math.abs(now - ts) > hub.config.timestampWindowMs) {
LOG.log(System.Logger.Level.WARNING, id + " rekey timestamp outside window; closing");
frames.close();
return;
}
// REKEY = Rand || Timestamp(I64 big-endian). Magic is excluded.
byte[] rekey = new byte[randLen + 8];
System.arraycopy(rand, 0, rekey, 0, randLen);
long t = ts;
for (int i = 7; i >= 0; i--) {
rekey[randLen + i] = (byte) (t & 0xFF);
t >>>= 8;
}
frames.switchCiphers(
Crypto.decryptCipher(rekey, Crypto.DIR_C2S),
Crypto.encryptCipher(rekey, Crypto.DIR_S2C));
frames.send(new byte[]{(byte) Protocol.CTL_SESSION_READY});
if (magic == Protocol.MAGIC_CONTROL) {
ControlSession session = new ControlSession(hub, frames, id);
frames.setHandler(session::onFrame);
closeCleanup = session::onClose;
LOG.log(System.Logger.Level.INFO, id + " control session established");
} else if (magic == Protocol.MAGIC_WORKER) {
WorkerConn worker = new WorkerConn(hub, frames, id);
frames.setHandler(worker::onFrame);
closeCleanup = worker::onClose;
LOG.log(System.Logger.Level.INFO, id + " worker conn established");
} else {
LOG.log(System.Logger.Level.WARNING, id + " bad magic " + magic + "; closing");
frames.close();
}
}
// ---- player connection ----
private void handlePlayer(String address) {
String pattern = Hub.normalizeAddress(address);
ControlSession session = hub.match(address);
if (session == null) {
LOG.log(System.Logger.Level.INFO, id + " no route for '" + pattern + "'; closing");
socket.close();
return;
}
byte[] cid = hub.newCid();
String cidHex = Hex.encode(cid);
String ip = socket.remoteAddress() != null ? socket.remoteAddress().host() : "0.0.0.0";
int port = socket.remoteAddress() != null ? socket.remoteAddress().port() : 0;
socket.pause();
Buffer buffered = hs.copy(); // handshake + any pipelined bytes, forwarded verbatim
PendingPlayer p = new PendingPlayer(cid, cidHex, socket, buffered, pattern, ip, port);
hub.addPending(p);
closeCleanup = () -> hub.removePending(cidHex);
session.sendControlRequest(cid, pattern, ip, port);
LOG.log(System.Logger.Level.INFO,
id + " player " + ip + ":" + port + " matched '" + pattern + "' cid=" + cidHex);
}
}
@@ -0,0 +1,42 @@
package io.icybear.redapricot;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Promise;
import io.vertx.core.net.NetServer;
import io.vertx.core.net.NetServerOptions;
/** Vert.x verticle that accepts every inbound connection on the hub port. */
public final class HubServer extends AbstractVerticle {
private static final System.Logger LOG = System.getLogger("redapricot.server");
private final Config config;
private Hub hub;
public HubServer(Config config) {
this.config = config;
}
@Override
public void start(Promise<Void> startPromise) {
hub = new Hub(vertx, config);
NetServerOptions opts = new NetServerOptions()
.setHost(config.host)
.setPort(config.port)
.setTcpNoDelay(true)
.setReuseAddress(true);
NetServer server = vertx.createNetServer(opts);
server.connectHandler(socket -> new HubConnection(hub, socket).start());
server.listen().onComplete(ar -> {
if (ar.succeeded()) {
LOG.log(System.Logger.Level.INFO,
"redapricot hub listening on " + config.host + ":" + ar.result().actualPort());
LOG.log(System.Logger.Level.INFO, "PSK handshake address: " + hub.pskAddress);
startPromise.complete();
} else {
startPromise.fail(ar.cause());
}
});
}
}
@@ -0,0 +1,32 @@
package io.icybear.redapricot;
import io.vertx.core.Vertx;
import io.vertx.core.VertxOptions;
import java.nio.file.Path;
/** Entry point: {@code java -jar redapricot-server.jar <config.json>}. */
public final class Main {
private static final System.Logger LOG = System.getLogger("redapricot.main");
public static void main(String[] args) throws Exception {
if (args.length < 1) {
System.err.println("usage: redapricot-server <config.json>");
System.exit(2);
}
Config config = Config.load(Path.of(args[0]));
// A single verticle instance keeps all connection handling on one event
// loop, so the shared hub state needs no locking.
Vertx vertx = Vertx.vertx(new VertxOptions());
vertx.deployVerticle(new HubServer(config)).onComplete(ar -> {
if (ar.failed()) {
LOG.log(System.Logger.Level.ERROR, "failed to start hub", ar.cause());
vertx.close();
System.exit(1);
}
});
Runtime.getRuntime().addShutdownHook(new Thread(vertx::close));
}
}
@@ -0,0 +1,27 @@
package io.icybear.redapricot;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.net.NetSocket;
/** A player connection awaiting a worker-conn takeover, keyed by CID. */
public final class PendingPlayer {
public final byte[] cid;
public final String cidHex;
public final NetSocket socket;
public final Buffer buffered; // raw bytes already read from the player (handshake + pipelined)
public final String pattern;
public final String playerIp;
public final int playerPort;
public long timerId = -1;
public PendingPlayer(byte[] cid, String cidHex, NetSocket socket, Buffer buffered,
String pattern, String playerIp, int playerPort) {
this.cid = cid;
this.cidHex = cidHex;
this.socket = socket;
this.buffered = buffered;
this.pattern = pattern;
this.playerIp = playerIp;
this.playerPort = playerPort;
}
}
@@ -0,0 +1,33 @@
package io.icybear.redapricot;
/** Shared redapricot protocol constants. See PROTOCOL.md. */
public final class Protocol {
private Protocol() {}
public static final int INTENT_REDAPRICOT = 17;
public static final int INTENT_RESERVED = 18;
public static final int MAGIC_CONTROL = 0x01;
public static final int MAGIC_WORKER = 0x02;
public static final int CID_LEN = 16;
public static final int MAX_FRAME = 1 << 20; // 1 MiB payload cap
// Control-session message types
public static final int CTL_SESSION_READY = 0x00;
public static final int CTL_REGISTER = 0x01;
public static final int CTL_UNREGISTER = 0x02;
public static final int CTL_REGISTER_ACK = 0x03;
public static final int CTL_CONTROL_REQUEST = 0x04;
public static final int CTL_PING = 0x05;
public static final int CTL_PONG = 0x06;
// Worker-conn mux frame types
public static final int MUX_SYN = 0x00;
public static final int MUX_DATA = 0x01;
public static final int MUX_FIN = 0x02;
public static final int MUX_RST = 0x03;
// Any redapricot connection
public static final int FRAME_ERROR = 0x7F;
}
@@ -0,0 +1,108 @@
package io.icybear.redapricot;
import io.icybear.redapricot.net.EncryptedFrames;
import io.icybear.redapricot.util.ProtoReader;
import io.icybear.redapricot.util.ProtoWriter;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.net.NetSocket;
import java.util.HashMap;
import java.util.Map;
/**
* An authenticated worker connection (Magic 0x02). Multiplexes many player
* streams; the client opens streams via SYN(CID) to take over pending players.
*/
public final class WorkerConn {
private static final System.Logger LOG = System.getLogger("redapricot.worker");
private final Hub hub;
private final EncryptedFrames frames;
private final String id;
private final Map<Integer, NetSocket> streams = new HashMap<>();
public WorkerConn(Hub hub, EncryptedFrames frames, String id) {
this.hub = hub;
this.frames = frames;
this.id = id;
}
public void onFrame(byte[] payload) {
ProtoReader r = new ProtoReader(payload);
int type = r.readUByte();
int sid = r.readVarInt();
switch (type) {
case Protocol.MUX_SYN -> handleSyn(sid, r.readBytes(Protocol.CID_LEN));
case Protocol.MUX_DATA -> handleData(sid, r.readBytes(r.remaining()));
case Protocol.MUX_FIN, Protocol.MUX_RST -> closeStream(sid);
case Protocol.FRAME_ERROR -> LOG.log(System.Logger.Level.WARNING,
"worker " + id + " error frame");
default -> LOG.log(System.Logger.Level.WARNING, "worker " + id + " unknown mux type " + type);
}
}
private void handleSyn(int sid, byte[] cid) {
PendingPlayer p = hub.takePending(cid);
if (p == null) {
LOG.log(System.Logger.Level.WARNING, "worker " + id + " SYN for unknown CID");
sendRst(sid);
return;
}
NetSocket player = p.socket;
streams.put(sid, player);
// From now on the player socket belongs to this stream.
player.handler(buf -> {
sendData(sid, buf.getBytes());
if (frames.writeQueueFull()) {
player.pause();
frames.socket().drainHandler(v -> player.resume());
}
});
player.closeHandler(v -> {
if (streams.remove(sid) != null) sendFin(sid);
});
player.exceptionHandler(t -> {
if (streams.remove(sid) != null) sendFin(sid);
});
// Forward the buffered handshake (and any pipelined bytes), then resume.
sendData(sid, p.buffered.getBytes());
player.resume();
LOG.log(System.Logger.Level.INFO,
"worker " + id + " stream " + sid + " bound to " + p.pattern);
}
private void handleData(int sid, byte[] data) {
NetSocket player = streams.get(sid);
if (player == null) return;
player.write(Buffer.buffer(data));
if (player.writeQueueFull()) {
frames.socket().pause();
player.drainHandler(v -> frames.socket().resume());
}
}
private void closeStream(int sid) {
NetSocket player = streams.remove(sid);
if (player != null) player.close();
}
private void sendData(int sid, byte[] data) {
frames.send(new ProtoWriter().u8(Protocol.MUX_DATA).varInt(sid).bytes(data).toBytes());
}
private void sendFin(int sid) {
frames.send(new ProtoWriter().u8(Protocol.MUX_FIN).varInt(sid).toBytes());
}
private void sendRst(int sid) {
frames.send(new ProtoWriter().u8(Protocol.MUX_RST).varInt(sid).toBytes());
}
public void onClose() {
for (NetSocket player : streams.values()) player.close();
streams.clear();
LOG.log(System.Logger.Level.INFO, "worker " + id + " closed");
}
}
@@ -0,0 +1,75 @@
package io.icybear.redapricot.crypto;
import io.icybear.redapricot.util.Hex;
import javax.crypto.Cipher;
import javax.crypto.spec.ChaCha20ParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
/**
* Crypto primitives for redapricot: SHA3 hashing, per-direction key derivation,
* and ChaCha20 stream ciphers. See PROTOCOL.md §3.
*/
public final class Crypto {
private Crypto() {}
public static final int DIR_C2S = 0x01;
public static final int DIR_S2C = 0x02;
public static byte[] sha3_224(byte[] in) {
return digest("SHA3-224", in);
}
/** Handshake address for Intent 17: lowercase hex of SHA3-224(PSK). */
public static String pskAddress(String psk) {
return Hex.encode(sha3_224(psk.getBytes(StandardCharsets.UTF_8)));
}
/** keyDir = SHA3-256(phaseKey || dirByte). */
public static byte[] deriveKey(byte[] phaseKey, int dir) {
try {
MessageDigest md = MessageDigest.getInstance("SHA3-256");
md.update(phaseKey);
md.update((byte) dir);
return md.digest();
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
/**
* Create a ChaCha20 stream cipher (RFC 8439) for a 32-byte key, 12-byte zero
* nonce, counter 0. Encryption and decryption are the identical XOR operation,
* so {@code opmode} is cosmetic; the keystream position advances across
* {@code update()} calls, giving a continuous stream.
*/
public static Cipher chacha20(byte[] key, int opmode) {
try {
Cipher c = Cipher.getInstance("ChaCha20");
c.init(opmode, new SecretKeySpec(key, "ChaCha20"),
new ChaCha20ParameterSpec(new byte[12], 0));
return c;
} catch (GeneralSecurityException e) {
throw new IllegalStateException("ChaCha20 unavailable", e);
}
}
public static Cipher encryptCipher(byte[] phaseKey, int dir) {
return chacha20(deriveKey(phaseKey, dir), Cipher.ENCRYPT_MODE);
}
public static Cipher decryptCipher(byte[] phaseKey, int dir) {
return chacha20(deriveKey(phaseKey, dir), Cipher.DECRYPT_MODE);
}
private static byte[] digest(String alg, byte[] in) {
try {
return MessageDigest.getInstance(alg).digest(in);
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,104 @@
package io.icybear.redapricot.net;
import io.icybear.redapricot.Protocol;
import io.icybear.redapricot.util.VarInt;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.net.NetSocket;
import javax.crypto.Cipher;
/**
* Length-prefixed encrypted frame transport over a NetSocket (PROTOCOL.md §3.1).
* The VarInt length prefix is plaintext; the payload is ChaCha20-encrypted. The
* cipher instances maintain a continuous per-direction keystream across frames.
*/
public final class EncryptedFrames {
public interface FrameHandler {
void handle(byte[] payload);
}
private final NetSocket socket;
private Cipher in;
private Cipher out;
private FrameHandler handler;
private Buffer buf = Buffer.buffer();
private boolean closed = false;
public EncryptedFrames(NetSocket socket, Cipher in, Cipher out, FrameHandler handler) {
this.socket = socket;
this.in = in;
this.out = out;
this.handler = handler;
}
public void setHandler(FrameHandler h) { this.handler = h; }
/** Swap both ciphers at a frame boundary (Phase A → Phase B rekey). */
public void switchCiphers(Cipher in, Cipher out) {
this.in = in;
this.out = out;
}
public NetSocket socket() { return socket; }
public boolean isClosed() { return closed; }
/** Feed raw incoming ciphertext (plaintext length prefixes + encrypted payloads). */
public void feed(Buffer incoming) {
if (closed) return;
if (incoming != null && incoming.length() > 0) buf.appendBuffer(incoming);
pump();
}
private void pump() {
while (!closed) {
VarInt.Read r;
try {
r = VarInt.tryRead(buf, 0);
} catch (RuntimeException e) {
close();
return;
}
if (r == null) return;
int payloadLen = r.value();
int hdr = r.size();
if (payloadLen < 0 || payloadLen > Protocol.MAX_FRAME) {
close();
return;
}
if (buf.length() < hdr + payloadLen) return;
byte[] ct = buf.getBytes(hdr, hdr + payloadLen);
byte[] pt = in.update(ct);
if (pt == null) pt = new byte[0];
buf = buf.getBuffer(hdr + payloadLen, buf.length());
FrameHandler h = handler;
if (h != null) {
try {
h.handle(pt);
} catch (RuntimeException e) {
close();
return;
}
}
}
}
/** Encrypt and send one frame payload. */
public void send(byte[] payload) {
if (closed) return;
byte[] ct = out.update(payload);
if (ct == null) ct = new byte[0];
Buffer f = Buffer.buffer(ct.length + VarInt.MAX_BYTES);
VarInt.write(f, ct.length);
f.appendBytes(ct);
socket.write(f);
}
public boolean writeQueueFull() { return socket.writeQueueFull(); }
public void close() {
if (closed) return;
closed = true;
socket.close();
}
}
@@ -0,0 +1,16 @@
package io.icybear.redapricot.util;
public final class Hex {
private Hex() {}
private static final char[] HEX = "0123456789abcdef".toCharArray();
public static String encode(byte[] in) {
char[] out = new char[in.length * 2];
for (int i = 0; i < in.length; i++) {
int v = in[i] & 0xFF;
out[i * 2] = HEX[v >>> 4];
out[i * 2 + 1] = HEX[v & 0x0F];
}
return new String(out);
}
}
@@ -0,0 +1,151 @@
package io.icybear.redapricot.util;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Minimal, dependency-free JSON parser sufficient for redapricot config files.
* Produces Map&lt;String,Object&gt;, List&lt;Object&gt;, String, Double, Boolean, null.
*/
public final class Json {
private final String s;
private int i;
private Json(String s) { this.s = s; }
public static Object parse(String text) {
Json p = new Json(text);
p.ws();
Object v = p.value();
p.ws();
if (p.i != p.s.length()) throw new IllegalArgumentException("trailing JSON at " + p.i);
return v;
}
@SuppressWarnings("unchecked")
public static Map<String, Object> parseObject(String text) {
Object v = parse(text);
if (!(v instanceof Map)) throw new IllegalArgumentException("expected JSON object");
return (Map<String, Object>) v;
}
private Object value() {
char c = peek();
return switch (c) {
case '{' -> object();
case '[' -> array();
case '"' -> string();
case 't', 'f' -> bool();
case 'n' -> nul();
default -> number();
};
}
private Map<String, Object> object() {
expect('{');
Map<String, Object> m = new LinkedHashMap<>();
ws();
if (peek() == '}') { i++; return m; }
while (true) {
ws();
String key = string();
ws();
expect(':');
ws();
m.put(key, value());
ws();
char c = next();
if (c == '}') return m;
if (c != ',') throw err("expected , or }");
}
}
private List<Object> array() {
expect('[');
List<Object> a = new ArrayList<>();
ws();
if (peek() == ']') { i++; return a; }
while (true) {
ws();
a.add(value());
ws();
char c = next();
if (c == ']') return a;
if (c != ',') throw err("expected , or ]");
}
}
private String string() {
expect('"');
StringBuilder sb = new StringBuilder();
while (true) {
char c = next();
if (c == '"') return sb.toString();
if (c == '\\') {
char e = next();
switch (e) {
case '"' -> sb.append('"');
case '\\' -> sb.append('\\');
case '/' -> sb.append('/');
case 'b' -> sb.append('\b');
case 'f' -> sb.append('\f');
case 'n' -> sb.append('\n');
case 'r' -> sb.append('\r');
case 't' -> sb.append('\t');
case 'u' -> {
int cp = Integer.parseInt(s.substring(i, i + 4), 16);
i += 4;
sb.append((char) cp);
}
default -> throw err("bad escape");
}
} else {
sb.append(c);
}
}
}
private Object number() {
int start = i;
while (i < s.length() && "+-0123456789.eE".indexOf(s.charAt(i)) >= 0) i++;
String num = s.substring(start, i);
if (num.isEmpty()) throw err("bad value");
if (num.contains(".") || num.contains("e") || num.contains("E")) return Double.parseDouble(num);
return Double.parseDouble(num); // keep numbers as Double uniformly
}
private Boolean bool() {
if (s.startsWith("true", i)) { i += 4; return Boolean.TRUE; }
if (s.startsWith("false", i)) { i += 5; return Boolean.FALSE; }
throw err("bad literal");
}
private Object nul() {
if (s.startsWith("null", i)) { i += 4; return null; }
throw err("bad literal");
}
private void ws() {
while (i < s.length() && Character.isWhitespace(s.charAt(i))) i++;
}
private char peek() {
if (i >= s.length()) throw err("unexpected end");
return s.charAt(i);
}
private char next() {
if (i >= s.length()) throw err("unexpected end");
return s.charAt(i++);
}
private void expect(char c) {
if (next() != c) throw err("expected " + c);
}
private IllegalArgumentException err(String msg) {
return new IllegalArgumentException("JSON: " + msg + " at index " + i);
}
}
@@ -0,0 +1,62 @@
package io.icybear.redapricot.util;
import java.nio.charset.StandardCharsets;
/** Cursor-based reader for redapricot/Minecraft primitive types over a byte array. */
public final class ProtoReader {
private final byte[] buf;
private int pos;
private final int end;
public ProtoReader(byte[] buf) { this(buf, 0, buf.length); }
public ProtoReader(byte[] buf, int off, int len) {
this.buf = buf;
this.pos = off;
this.end = off + len;
}
public int remaining() { return end - pos; }
public int readUByte() {
if (pos >= end) throw new IllegalStateException("underflow");
return buf[pos++] & 0xFF;
}
public int readVarInt() {
int value = 0;
int shift = 0;
while (true) {
int b = readUByte();
value |= (b & 0x7F) << shift;
if ((b & 0x80) == 0) return value;
shift += 7;
if (shift >= 32) throw new IllegalArgumentException("VarInt too big");
}
}
public int readU16() {
int hi = readUByte();
int lo = readUByte();
return (hi << 8) | lo;
}
public long readI64() {
long v = 0;
for (int i = 0; i < 8; i++) v = (v << 8) | readUByte();
return v;
}
public byte[] readBytes(int n) {
if (n < 0 || n > remaining()) throw new IllegalStateException("bad length " + n);
byte[] out = new byte[n];
System.arraycopy(buf, pos, out, 0, n);
pos += n;
return out;
}
public String readString() {
int len = readVarInt();
return new String(readBytes(len), StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,35 @@
package io.icybear.redapricot.util;
import io.vertx.core.buffer.Buffer;
import java.nio.charset.StandardCharsets;
/** Builder for redapricot/Minecraft primitive types, backed by a Vert.x Buffer. */
public final class ProtoWriter {
private final Buffer b = Buffer.buffer();
public ProtoWriter u8(int v) { b.appendByte((byte) v); return this; }
public ProtoWriter varInt(int v) { VarInt.write(b, v); return this; }
public ProtoWriter u16(int v) {
b.appendByte((byte) (v >>> 8));
b.appendByte((byte) v);
return this;
}
public ProtoWriter i64(long v) { b.appendLong(v); return this; }
public ProtoWriter bytes(byte[] x) { b.appendBytes(x); return this; }
public ProtoWriter string(String s) {
byte[] u = s.getBytes(StandardCharsets.UTF_8);
varInt(u.length);
b.appendBytes(u);
return this;
}
public byte[] toBytes() { return b.getBytes(); }
public Buffer buffer() { return b; }
}
@@ -0,0 +1,51 @@
package io.icybear.redapricot.util;
import io.vertx.core.buffer.Buffer;
/** Minecraft-style VarInt (LEB128, 7 data bits/byte, max 5 bytes). */
public final class VarInt {
private VarInt() {}
public static final int MAX_BYTES = 5;
/** Result of a partial read: either complete (value/size) or null when more bytes are needed. */
public record Read(int value, int size) {}
/** Write {@code value} as a VarInt to the buffer. */
public static void write(Buffer buf, int value) {
while ((value & ~0x7F) != 0) {
buf.appendByte((byte) ((value & 0x7F) | 0x80));
value >>>= 7;
}
buf.appendByte((byte) (value & 0x7F));
}
/** Encoded size in bytes of {@code value}. */
public static int size(int value) {
int n = 1;
while ((value & ~0x7F) != 0) { value >>>= 7; n++; }
return n;
}
/**
* Try to read a VarInt from {@code buf} starting at {@code off}, without consuming.
* Returns null if the buffer does not yet hold the full VarInt.
* Throws IllegalArgumentException if it exceeds 5 bytes.
*/
public static Read tryRead(Buffer buf, int off) {
int value = 0;
int shift = 0;
int i = off;
while (true) {
if (i >= buf.length()) return null; // need more bytes
int b = buf.getByte(i) & 0xFF;
value |= (b & 0x7F) << shift;
i++;
if ((b & 0x80) == 0) {
return new Read(value, i - off);
}
shift += 7;
if (shift >= 32) throw new IllegalArgumentException("VarInt too big");
}
}
}
@@ -0,0 +1,74 @@
package io.icybear.redapricot;
import io.icybear.redapricot.crypto.Crypto;
import io.icybear.redapricot.util.ProtoReader;
import io.icybear.redapricot.util.ProtoWriter;
import io.icybear.redapricot.util.VarInt;
import io.vertx.core.buffer.Buffer;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
class CryptoCodecTest {
@Test
void varIntRoundTrip() {
int[] cases = {0, 1, 127, 128, 255, 300, 16384, 2097151, 1 << 30};
for (int v : cases) {
Buffer b = Buffer.buffer();
VarInt.write(b, v);
assertEquals(VarInt.size(v), b.length(), "size for " + v);
VarInt.Read r = VarInt.tryRead(b, 0);
assertEquals(v, r.value());
assertEquals(b.length(), r.size());
}
}
@Test
void varIntTryReadNeedsMoreBytes() {
Buffer partial = Buffer.buffer();
partial.appendByte((byte) 0x80); // continuation set, but no following byte
assertNull(VarInt.tryRead(partial, 0));
}
@Test
void protoStringAndTypesRoundTrip() {
byte[] enc = new ProtoWriter()
.u8(0x04).string("mc.EXAMPLE.com").string("127.0.0.1").u16(45123).i64(1234567890123L)
.toBytes();
ProtoReader r = new ProtoReader(enc);
assertEquals(0x04, r.readUByte());
assertEquals("mc.EXAMPLE.com", r.readString());
assertEquals("127.0.0.1", r.readString());
assertEquals(45123, r.readU16());
assertEquals(1234567890123L, r.readI64());
}
/** Locked against the identical Go client assertion (SHA3-224 of "test-psk"). */
@Test
void pskAddressMatchesReference() {
assertEquals("90188f2d84e273e4d6fb27194b4a88ad10bcc20de00c493beae6d18f",
Crypto.pskAddress("test-psk"));
}
@Test
void perDirectionKeysDifferAndAreStable() {
byte[] pk = "phase-key".getBytes();
byte[] c2s = Crypto.deriveKey(pk, Crypto.DIR_C2S);
byte[] s2c = Crypto.deriveKey(pk, Crypto.DIR_S2C);
assertEquals(32, c2s.length);
assertEquals(32, s2c.length);
assertArrayEquals(c2s, Crypto.deriveKey(pk, Crypto.DIR_C2S)); // deterministic
assertFalse(java.util.Arrays.equals(c2s, s2c)); // directions differ
}
@Test
void normalizeAddressStripsFmlAndCase() {
assertEquals("mc.example.com", Hub.normalizeAddress("MC.Example.com"));
assertEquals("mc.example.com", Hub.normalizeAddress("mc.example.com."));
assertEquals("mc.example.com", Hub.normalizeAddress("MC.Example.com\u0000FML\u00000"));
}
}