initial commit

This commit is contained in:
iceBear67
2026-07-15 14:28:58 +08:00
commit 6e0d7ec33f
47 changed files with 4022 additions and 0 deletions
+225
View File
@@ -0,0 +1,225 @@
# redapricot (红杏)
A **central-hub P2P tunnel that speaks the Minecraft Java Edition protocol.**
redapricot lets a Minecraft server that lives behind NAT/CGNAT (a "client")
publish itself through a public **hub**, so that players connecting to the hub
are transparently forwarded to the hidden server — no port forwarding required.
It works by *extending* the Minecraft handshake: the hub listens on a single
port and tells players apart from tunnel endpoints by the handshake `Intent`
field, so a vanilla Minecraft client needs no modification.
> 红杏出墙 — "the red apricot reaches over the wall": a server behind a wall,
> made reachable from the outside.
```
Player ──MC──▶ Hub (Java) ══ Worker Conn (mux) ══▶ Client (Go) ──MC──▶ Real MC server
vanilla client public IP encrypted, pooled behind NAT (localhost)
│ ▲
└────────────── Control Session ─────────┘
(pattern registration + control requests)
```
* **Hub / server** — Java 21+, [Vert.x](https://vertx.io). One public TCP port.
* **Client** — Go. Registers hostnames with the hub and forwards to real servers.
* **Player** — any Minecraft client. Connects to the hub using a registered
hostname; the connection lands on the hidden server.
The full wire protocol is specified in **[PROTOCOL.md](PROTOCOL.md)**; the
design rationale is in **[docs/architecture.md](docs/architecture.md)**.
---
## How it works (in one paragraph)
A client opens a **control session** to the hub: it sends a Minecraft handshake
with `Intent = 17` and a `Server Address` equal to `hex(SHA3-224(PSK))`, then
the link switches to ChaCha20-encrypted frames keyed by the shared **PSK**,
re-keyed to a per-connection secret. Over that session the client **registers**
one or more hostname patterns. When a player connects to the hub with a matching
hostname (and any normal `Intent`), the hub assigns a random **CID**, buffers
the player's bytes, and asks the client (via the control session) to take over.
The client picks a **worker connection** — a multiplexed, encrypted TCP link
that carries many players as lightweight *streams* — opens a stream for that CID,
dials the real destination (optionally announcing the player's real IP with the
**HAProxy v2** protocol), and bridges the two ends. Worker connections are
pooled: the client uses up to `maxConn` of them and always places a new stream
on the least-loaded one.
## Repository layout
```
PROTOCOL.md normative wire spec (read this to build another impl)
docs/architecture.md design, sequence diagrams, threading, limitations
server/ Java hub (Gradle, Vert.x)
src/main/java/io/icybear/redapricot/
client/ Go client library
wire/ VarInt/MC codec, SHA3+ChaCha20, encrypted framing
cmd/redapricot-client/ Go client binary
e2e/ end-to-end integration tests (Java hub + Go client)
scripts/build.sh build hub + client
scripts/e2e.sh build, then run all tests
```
## Prerequisites
* **JDK 21+** (the hub compiles at Java 21; it runs fine on newer JDKs).
* **Gradle 8.5+ / 9.x** (Gradle 9.2.1 is used here).
* **Go 1.24+** (needs the standard-library `crypto/sha3`; developed with Go 1.26).
If you use [SDKMAN!](https://sdkman.io), `scripts/build.sh` auto-discovers a JDK
at `~/.sdkman/candidates/java/current` and Gradle at
`~/.sdkman/candidates/gradle/current`. Otherwise set `JAVA_HOME` and make
`gradle` / `go` available on `PATH`.
## Build
```bash
./scripts/build.sh
```
This produces:
* the hub at `server/build/install/redapricot-server/bin/redapricot-server`
* the client binary at `bin/redapricot-client`
Alternatively, build a single self-contained **fat jar** for the hub (via the
[Shadow](https://gradleup.com/shadow/) plugin):
```bash
gradle -p server shadowJar # (with JAVA_HOME set)
java -jar server/build/libs/redapricot-server-0.1.0-all.jar hub.json
```
## Run
**1. Start the hub** (public machine). Copy and edit the example config:
```bash
cp server/config.example.json hub.json # set a strong "psk"
server/build/install/redapricot-server/bin/redapricot-server hub.json
```
On startup the hub logs its PSK handshake address, e.g.
`PSK handshake address: 90188f2d...` — this confirms the PSK the hub expects.
**2. Start the client** (machine next to the real Minecraft server). Edit the
example config so `psk` matches the hub, `server` points at the hub, and each
mapping routes a hostname to a real server:
```bash
cp client/config.example.json client.json
# {
# "server": "hub.example.com:25565",
# "psk": "same-as-the-hub",
# "maxConn": 4,
# "mappings": [
# { "pattern": "mc.example.com", "destination": "127.0.0.1:25566", "proxyProtocol": true }
# ]
# }
bin/redapricot-client client.json
```
**3. Connect a player.** Point a DNS record for `mc.example.com` at the hub (or
just add the hub's IP with that hostname), then join `mc.example.com` in
Minecraft. The hub matches the pattern and tunnels you to `127.0.0.1:25566`
behind the client. With `proxyProtocol: true`, the real server sees your true IP
(enable `proxy-protocol` / a compatible front-end on that server to consume it).
## Container image (client)
The Go client ships two ways to build an image.
**Dockerfile** (multi-stage, distroless static, build from the repo root):
```bash
docker build -t redapricot-client .
docker run --rm -v "$PWD/client.json:/etc/redapricot/client.json" \
redapricot-client /etc/redapricot/client.json
```
**[ko](https://ko.build)** (Dockerfile-less; used by CI) builds and pushes
straight from the Go package:
```bash
export KO_DOCKER_REPO=your-registry/namespace/redapricot-client
ko build ./cmd/redapricot-client --bare
```
CI publishes the image on version tags via
`.github/workflows/publish-client-image.yml`. **Before using it, edit the
`KO_DOCKER_REPO` placeholder** at the top of that file to your registry, and (for
a non-ghcr.io registry) set the `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` repo
secrets. The base image and build flags live in `.ko.yaml`.
## Configuration reference
### Hub (`server/config.example.json`)
| Key | Default | Meaning |
|---------------------|------------------|---------|
| `listen` | `0.0.0.0:25565` | Host:port the hub accepts all connections on. |
| `psk` | *(required)* | Shared secret; must match every client. |
| `timestampWindowMs` | `30000` | Allowed clock skew for a client's rekey timestamp. |
| `pendingTimeoutMs` | `10000` | How long a matched player waits for a worker to take over. |
### Client (`client/config.example.json`)
| Key | Default | Meaning |
|------------------|--------------------|---------|
| `server` | *(required)* | Hub `host:port`. |
| `psk` | *(required)* | Shared secret; must match the hub. |
| `maxConn` | `1` (clamped 18) | Max worker connections in the pool. |
| `pingIntervalMs` | `20000` | Control-session keepalive interval. |
| `mappings[]` | *(≥1 required)* | Route table (below). |
| `mappings[].pattern` | — | Hostname players use (matched case-insensitively). |
| `mappings[].destination` | — | Real server `host:port` to forward to. |
| `mappings[].proxyProtocol` | `false` | Prepend a HAProxy v2 header carrying the player's IP. |
## Testing
```bash
./scripts/e2e.sh # build, Go unit tests, then the full e2e suite
```
Or run pieces directly:
```bash
# Go unit tests (codec, crypto, encrypted framing)
go test ./client/... -v
# Java unit tests (VarInt/codec, SHA3-224 vector, key derivation, normalization)
JAVA_HOME=$HOME/.sdkman/candidates/java/current \
gradle -p server test
# End-to-end (spawns the real Java hub + in-process Go client + a mock destination)
go test ./e2e/... -v
```
The e2e suite covers: a full player round-trip with verbatim handshake
forwarding and case-insensitive matching, multi-megabyte transfers, concurrent
streams spreading across multiple worker connections, HAProxy v2 source-address
propagation, player- and destination-initiated disconnect propagation, wrong-PSK
rejection, and dropping of unmatched hostnames. The Go and Java crypto layers are
independently pinned to the same SHA3-224 test vector so they cannot silently
drift apart.
## Design notes & limitations
* **Security is deliberately light.** The PSK proves membership; traffic is
ChaCha20-encrypted (no AEAD tag) to minimize overhead. This protects against
casual sniffing, not a determined active attacker (see the note at the top of
[PROTOCOL.md](PROTOCOL.md) and [docs/architecture.md](docs/architecture.md) §8).
* **No per-stream flow control.** Multiplexing relies on TCP back-pressure per
worker connection, so one very slow stream can head-of-line-block others on
the same connection. Raising `maxConn` spreads load. Fine for interactive
Minecraft traffic; not a general-purpose high-throughput mux.
* **Single hub event loop.** The hub deploys one Vert.x verticle, so all state
is confined to one event loop (no locking). Throughput is bounded by one core;
ample for hundreds of players, not designed for tens of thousands.
## Attribution
The Minecraft protocol reference bundled as `CURRENT_MC_PROTO.txt` is derived
from the [Minecraft Wiki](https://minecraft.wiki/w/Java_Edition_protocol/Packets)
and is licensed under [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0/).