Files
redapricot/README.md
T
2026-07-15 14:59:32 +00:00

232 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 — each a **regular expression**. When a player
connects to the hub with a hostname that matches a registered pattern (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 hostname against the registered regex patterns
and tunnels you to `127.0.0.1:25566` behind the client. With `proxyProtocol: true`, the real server sees your true IP
(enable `proxy-protocol` / a compatible front-end on that server to consume it).
## Container image (client)
The Go client ships two ways to build an image.
**Dockerfile** (multi-stage, distroless static, build from the repo root):
```bash
docker build -t redapricot-client .
docker run --rm -v "$PWD/client.json:/etc/redapricot/client.json" \
redapricot-client /etc/redapricot/client.json
```
**[ko](https://ko.build)** (Dockerfile-less; used by CI) builds and pushes
straight from the Go package:
```bash
export KO_DOCKER_REPO=your-registry/namespace/redapricot-client
ko build ./cmd/redapricot-client --bare
```
CI publishes the image on version tags via
`.github/workflows/publish-client-image.yml`. **Before using it, edit the
`KO_DOCKER_REPO` placeholder** at the top of that file to your registry, and (for
a non-ghcr.io registry) set the `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` repo
secrets. The base image and build flags live in `.ko.yaml`.
## Configuration reference
### Hub (`server/config.example.json`)
| Key | Default | Meaning |
|---------------------|------------------|---------|
| `listen` | `0.0.0.0:25565` | Host:port the hub accepts all connections on. |
| `psk` | *(required)* | Shared secret; must match every client. |
| `timestampWindowMs` | `30000` | Allowed clock skew for a client's rekey timestamp. |
| `pendingTimeoutMs` | `10000` | How long a matched player waits for a worker to take over. |
### Client (`client/config.example.json`)
| Key | Default | Meaning |
|------------------|--------------------|---------|
| `server` | *(required)* | Hub `host:port`. |
| `psk` | *(required)* | Shared secret; must match the hub. |
| `maxConn` | `1` (clamped 18) | Max worker connections in the pool. |
| `pingIntervalMs` | `20000` | Control-session keepalive interval. |
| `mappings[]` | *(≥1 required)* | Route table (below). |
| `mappings[].pattern` | — | Regex matched against the whole player hostname, case-insensitively. Escape dots (`mc\.example\.com`); `.` is a wildcard. |
| `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, regex wildcard pattern routing,
multi-megabyte transfers, concurrent
streams spreading across multiple worker connections, HAProxy v2 source-address
propagation, player- and destination-initiated disconnect propagation, wrong-PSK
rejection, dropping of unmatched hostnames, stream isolation under a slow
player and under a slow destination (no head-of-line blocking), and rejection
of pre-flow-control peers. 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).
* **Per-stream flow control.** Each stream has credit-based windows in both
directions (windows exchanged at session setup, default 256 KiB), so a slow
player or slow destination jams only its own stream at a bounded buffer — no
application-level head-of-line blocking between streams. What remains is
TCP-level HOL (packet loss stalls a whole worker connection briefly);
raising `maxConn` spreads that.
* **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/).