From 6e0d7ec33f86448537ed74556a486f7757abbf68 Mon Sep 17 00:00:00 2001 From: iceBear67 Date: Wed, 15 Jul 2026 14:28:58 +0800 Subject: [PATCH] initial commit --- .dockerignore | 11 + .github/workflows/publish-client-image.yml | 70 ++++ .gitignore | 13 + .ko.yaml | 13 + Dockerfile | 31 ++ IDEA.md | 40 +++ PROTOCOL.md | 334 ++++++++++++++++++ README.md | 225 ++++++++++++ client/client.go | 250 +++++++++++++ client/config.example.json | 13 + client/config.go | 94 +++++ client/proxyproto.go | 43 +++ client/wire/crypto.go | 44 +++ client/wire/framedconn.go | 78 ++++ client/wire/mc.go | 122 +++++++ client/wire/varint.go | 51 +++ client/wire/wire_test.go | 105 ++++++ client/worker.go | 324 +++++++++++++++++ cmd/redapricot-client/main.go | 39 ++ docs/architecture.md | 194 ++++++++++ e2e/doc.go | 5 + e2e/e2e_test.go | 265 ++++++++++++++ e2e/harness_test.go | 304 ++++++++++++++++ go.mod | 7 + go.sum | 4 + scripts/build.sh | 31 ++ scripts/e2e.sh | 14 + server/build.gradle.kts | 55 +++ server/config.example.json | 6 + server/settings.gradle.kts | 1 + .../java/io/icybear/redapricot/Config.java | 48 +++ .../io/icybear/redapricot/ControlSession.java | 79 +++++ .../main/java/io/icybear/redapricot/Hub.java | 100 ++++++ .../io/icybear/redapricot/HubConnection.java | 199 +++++++++++ .../java/io/icybear/redapricot/HubServer.java | 42 +++ .../main/java/io/icybear/redapricot/Main.java | 32 ++ .../io/icybear/redapricot/PendingPlayer.java | 27 ++ .../java/io/icybear/redapricot/Protocol.java | 33 ++ .../io/icybear/redapricot/WorkerConn.java | 108 ++++++ .../io/icybear/redapricot/crypto/Crypto.java | 75 ++++ .../redapricot/net/EncryptedFrames.java | 104 ++++++ .../java/io/icybear/redapricot/util/Hex.java | 16 + .../java/io/icybear/redapricot/util/Json.java | 151 ++++++++ .../icybear/redapricot/util/ProtoReader.java | 62 ++++ .../icybear/redapricot/util/ProtoWriter.java | 35 ++ .../io/icybear/redapricot/util/VarInt.java | 51 +++ .../icybear/redapricot/CryptoCodecTest.java | 74 ++++ 47 files changed, 4022 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/publish-client-image.yml create mode 100644 .gitignore create mode 100644 .ko.yaml create mode 100644 Dockerfile create mode 100644 IDEA.md create mode 100644 PROTOCOL.md create mode 100644 README.md create mode 100644 client/client.go create mode 100644 client/config.example.json create mode 100644 client/config.go create mode 100644 client/proxyproto.go create mode 100644 client/wire/crypto.go create mode 100644 client/wire/framedconn.go create mode 100644 client/wire/mc.go create mode 100644 client/wire/varint.go create mode 100644 client/wire/wire_test.go create mode 100644 client/worker.go create mode 100644 cmd/redapricot-client/main.go create mode 100644 docs/architecture.md create mode 100644 e2e/doc.go create mode 100644 e2e/e2e_test.go create mode 100644 e2e/harness_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100755 scripts/build.sh create mode 100755 scripts/e2e.sh create mode 100644 server/build.gradle.kts create mode 100644 server/config.example.json create mode 100644 server/settings.gradle.kts create mode 100644 server/src/main/java/io/icybear/redapricot/Config.java create mode 100644 server/src/main/java/io/icybear/redapricot/ControlSession.java create mode 100644 server/src/main/java/io/icybear/redapricot/Hub.java create mode 100644 server/src/main/java/io/icybear/redapricot/HubConnection.java create mode 100644 server/src/main/java/io/icybear/redapricot/HubServer.java create mode 100644 server/src/main/java/io/icybear/redapricot/Main.java create mode 100644 server/src/main/java/io/icybear/redapricot/PendingPlayer.java create mode 100644 server/src/main/java/io/icybear/redapricot/Protocol.java create mode 100644 server/src/main/java/io/icybear/redapricot/WorkerConn.java create mode 100644 server/src/main/java/io/icybear/redapricot/crypto/Crypto.java create mode 100644 server/src/main/java/io/icybear/redapricot/net/EncryptedFrames.java create mode 100644 server/src/main/java/io/icybear/redapricot/util/Hex.java create mode 100644 server/src/main/java/io/icybear/redapricot/util/Json.java create mode 100644 server/src/main/java/io/icybear/redapricot/util/ProtoReader.java create mode 100644 server/src/main/java/io/icybear/redapricot/util/ProtoWriter.java create mode 100644 server/src/main/java/io/icybear/redapricot/util/VarInt.java create mode 100644 server/src/test/java/io/icybear/redapricot/CryptoCodecTest.java diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6c94180 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.github/workflows/publish-client-image.yml b/.github/workflows/publish-client-image.yml new file mode 100644 index 0000000..12e7693 --- /dev/null +++ b/.github/workflows/publish-client-image.yml @@ -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: // + # 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 }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a4af5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Java / Gradle +server/build/ +server/.gradle/ +.gradle/ + +# Go build output +/bin/ + +# Editor / OS +*.log +.DS_Store +.idea/ +*.iml diff --git a/.ko.yaml b/.ko.yaml new file mode 100644 index 0000000..8161e67 --- /dev/null +++ b/.ko.yaml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ca9f282 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/IDEA.md b/IDEA.md new file mode 100644 index 0000000..63b294c --- /dev/null +++ b/IDEA.md @@ -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). diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 0000000..7e9f415 --- /dev/null +++ b/PROTOCOL.md @@ -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]` | +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..f566c39 --- /dev/null +++ b/README.md @@ -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 1–8) | 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/). diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..04591a6 --- /dev/null +++ b/client/client.go @@ -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() +} diff --git a/client/config.example.json b/client/config.example.json new file mode 100644 index 0000000..5893629 --- /dev/null +++ b/client/config.example.json @@ -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 + } + ] +} diff --git a/client/config.go b/client/config.go new file mode 100644 index 0000000..a9975b5 --- /dev/null +++ b/client/config.go @@ -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 +} diff --git a/client/proxyproto.go b/client/proxyproto.go new file mode 100644 index 0000000..91be7cd --- /dev/null +++ b/client/proxyproto.go @@ -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 +} diff --git a/client/wire/crypto.go b/client/wire/crypto.go new file mode 100644 index 0000000..142fdfa --- /dev/null +++ b/client/wire/crypto.go @@ -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 +} diff --git a/client/wire/framedconn.go b/client/wire/framedconn.go new file mode 100644 index 0000000..d309cfa --- /dev/null +++ b/client/wire/framedconn.go @@ -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() } diff --git a/client/wire/mc.go b/client/wire/mc.go new file mode 100644 index 0000000..11d6ba0 --- /dev/null +++ b/client/wire/mc.go @@ -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...) +} diff --git a/client/wire/varint.go b/client/wire/varint.go new file mode 100644 index 0000000..c2b6fe0 --- /dev/null +++ b/client/wire/varint.go @@ -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 +} diff --git a/client/wire/wire_test.go b/client/wire/wire_test.go new file mode 100644 index 0000000..86c985e --- /dev/null +++ b/client/wire/wire_test.go @@ -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)) + } +} diff --git a/client/worker.go b/client/worker.go new file mode 100644 index 0000000..12fbfc1 --- /dev/null +++ b/client/worker.go @@ -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) + } +} diff --git a/cmd/redapricot-client/main.go b/cmd/redapricot-client/main.go new file mode 100644 index 0000000..01dc5a1 --- /dev/null +++ b/cmd/redapricot-client/main.go @@ -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 ") + } + + 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() +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..2d02c9a --- /dev/null +++ b/docs/architecture.md @@ -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. diff --git a/e2e/doc.go b/e2e/doc.go new file mode 100644 index 0000000..c9cc134 --- /dev/null +++ b/e2e/doc.go @@ -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 diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go new file mode 100644 index 0000000..1e62aa5 --- /dev/null +++ b/e2e/e2e_test.go @@ -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") + } +} diff --git a/e2e/harness_test.go b/e2e/harness_test.go new file mode 100644 index 0000000..578f8c9 --- /dev/null +++ b/e2e/harness_test.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7d1282d --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5dc9c1f --- /dev/null +++ b/go.sum @@ -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= diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..bf57073 --- /dev/null +++ b/scripts/build.sh @@ -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 <> Build complete. + Hub: $ROOT/server/build/install/redapricot-server/bin/redapricot-server + Client: $ROOT/bin/redapricot-client +EOF diff --git a/scripts/e2e.sh b/scripts/e2e.sh new file mode 100755 index 0000000..dc49814 --- /dev/null +++ b/scripts/e2e.sh @@ -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 ) diff --git a/server/build.gradle.kts b/server/build.gradle.kts new file mode 100644 index 0000000..5b25ca3 --- /dev/null +++ b/server/build.gradle.kts @@ -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--all.jar +// java -jar build/libs/redapricot-server-0.1.0-all.jar +// mergeServiceFiles() is required so Vert.x/Netty SPI (META-INF/services/*) +// survives the relocation into a single jar. +tasks.named("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. diff --git a/server/config.example.json b/server/config.example.json new file mode 100644 index 0000000..803c98d --- /dev/null +++ b/server/config.example.json @@ -0,0 +1,6 @@ +{ + "listen": "0.0.0.0:25565", + "psk": "change-me-to-a-long-random-passphrase", + "timestampWindowMs": 30000, + "pendingTimeoutMs": 10000 +} diff --git a/server/settings.gradle.kts b/server/settings.gradle.kts new file mode 100644 index 0000000..d372695 --- /dev/null +++ b/server/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "redapricot-server" diff --git a/server/src/main/java/io/icybear/redapricot/Config.java b/server/src/main/java/io/icybear/redapricot/Config.java new file mode 100644 index 0000000..08e6d77 --- /dev/null +++ b/server/src/main/java/io/icybear/redapricot/Config.java @@ -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 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 m, String k, String def) { + Object v = m.get(k); + return v == null ? def : v.toString(); + } + + private static long num(Map m, String k, long def) { + Object v = m.get(k); + return v == null ? def : (long) ((Number) v).doubleValue(); + } +} diff --git a/server/src/main/java/io/icybear/redapricot/ControlSession.java b/server/src/main/java/io/icybear/redapricot/ControlSession.java new file mode 100644 index 0000000..9a51051 --- /dev/null +++ b/server/src/main/java/io/icybear/redapricot/ControlSession.java @@ -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"); + } +} diff --git a/server/src/main/java/io/icybear/redapricot/Hub.java b/server/src/main/java/io/icybear/redapricot/Hub.java new file mode 100644 index 0000000..cd33ccb --- /dev/null +++ b/server/src/main/java/io/icybear/redapricot/Hub.java @@ -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 patterns = new ConcurrentHashMap<>(); + private final Map 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; + } +} diff --git a/server/src/main/java/io/icybear/redapricot/HubConnection.java b/server/src/main/java/io/icybear/redapricot/HubConnection.java new file mode 100644 index 0000000..a260dc2 --- /dev/null +++ b/server/src/main/java/io/icybear/redapricot/HubConnection.java @@ -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); + } +} diff --git a/server/src/main/java/io/icybear/redapricot/HubServer.java b/server/src/main/java/io/icybear/redapricot/HubServer.java new file mode 100644 index 0000000..12b8c2d --- /dev/null +++ b/server/src/main/java/io/icybear/redapricot/HubServer.java @@ -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 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()); + } + }); + } +} diff --git a/server/src/main/java/io/icybear/redapricot/Main.java b/server/src/main/java/io/icybear/redapricot/Main.java new file mode 100644 index 0000000..177f66a --- /dev/null +++ b/server/src/main/java/io/icybear/redapricot/Main.java @@ -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 }. */ +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 "); + 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)); + } +} diff --git a/server/src/main/java/io/icybear/redapricot/PendingPlayer.java b/server/src/main/java/io/icybear/redapricot/PendingPlayer.java new file mode 100644 index 0000000..cc581ee --- /dev/null +++ b/server/src/main/java/io/icybear/redapricot/PendingPlayer.java @@ -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; + } +} diff --git a/server/src/main/java/io/icybear/redapricot/Protocol.java b/server/src/main/java/io/icybear/redapricot/Protocol.java new file mode 100644 index 0000000..67d6f62 --- /dev/null +++ b/server/src/main/java/io/icybear/redapricot/Protocol.java @@ -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; +} diff --git a/server/src/main/java/io/icybear/redapricot/WorkerConn.java b/server/src/main/java/io/icybear/redapricot/WorkerConn.java new file mode 100644 index 0000000..f84fa2d --- /dev/null +++ b/server/src/main/java/io/icybear/redapricot/WorkerConn.java @@ -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 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"); + } +} diff --git a/server/src/main/java/io/icybear/redapricot/crypto/Crypto.java b/server/src/main/java/io/icybear/redapricot/crypto/Crypto.java new file mode 100644 index 0000000..be8a611 --- /dev/null +++ b/server/src/main/java/io/icybear/redapricot/crypto/Crypto.java @@ -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); + } + } +} diff --git a/server/src/main/java/io/icybear/redapricot/net/EncryptedFrames.java b/server/src/main/java/io/icybear/redapricot/net/EncryptedFrames.java new file mode 100644 index 0000000..444db0d --- /dev/null +++ b/server/src/main/java/io/icybear/redapricot/net/EncryptedFrames.java @@ -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(); + } +} diff --git a/server/src/main/java/io/icybear/redapricot/util/Hex.java b/server/src/main/java/io/icybear/redapricot/util/Hex.java new file mode 100644 index 0000000..4096d57 --- /dev/null +++ b/server/src/main/java/io/icybear/redapricot/util/Hex.java @@ -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); + } +} diff --git a/server/src/main/java/io/icybear/redapricot/util/Json.java b/server/src/main/java/io/icybear/redapricot/util/Json.java new file mode 100644 index 0000000..0eb0c7c --- /dev/null +++ b/server/src/main/java/io/icybear/redapricot/util/Json.java @@ -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<String,Object>, List<Object>, 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 parseObject(String text) { + Object v = parse(text); + if (!(v instanceof Map)) throw new IllegalArgumentException("expected JSON object"); + return (Map) 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 object() { + expect('{'); + Map 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 array() { + expect('['); + List 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); + } +} diff --git a/server/src/main/java/io/icybear/redapricot/util/ProtoReader.java b/server/src/main/java/io/icybear/redapricot/util/ProtoReader.java new file mode 100644 index 0000000..20761c1 --- /dev/null +++ b/server/src/main/java/io/icybear/redapricot/util/ProtoReader.java @@ -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); + } +} diff --git a/server/src/main/java/io/icybear/redapricot/util/ProtoWriter.java b/server/src/main/java/io/icybear/redapricot/util/ProtoWriter.java new file mode 100644 index 0000000..aa12049 --- /dev/null +++ b/server/src/main/java/io/icybear/redapricot/util/ProtoWriter.java @@ -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; } +} diff --git a/server/src/main/java/io/icybear/redapricot/util/VarInt.java b/server/src/main/java/io/icybear/redapricot/util/VarInt.java new file mode 100644 index 0000000..7ee6dd9 --- /dev/null +++ b/server/src/main/java/io/icybear/redapricot/util/VarInt.java @@ -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"); + } + } +} diff --git a/server/src/test/java/io/icybear/redapricot/CryptoCodecTest.java b/server/src/test/java/io/icybear/redapricot/CryptoCodecTest.java new file mode 100644 index 0000000..5ff0fdc --- /dev/null +++ b/server/src/test/java/io/icybear/redapricot/CryptoCodecTest.java @@ -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")); + } +}