Add Spinner, a Minecraft hostname-routing reverse proxy
CI / test (push) Successful in 2m32s

Routes Java Edition connections by the hostname in the handshake, with
wildcard domains, backend failover, PROXY protocol v2, cached server-list
status from health probes, hot-reloaded config, and an admin page and
metrics endpoint. Includes CI, tagged releases and a container image.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-26 19:40:40 -06:00
1 parent e640c5bf02
commit b53af0e196
29 files changed
+2640

No files matched your search

+83
View File
@@ -1,2 +1,85 @@
# Spinner
A small Minecraft Java reverse proxy. One public port, many backend servers, chosen by the
hostname the player typed (`survival.example.com` and `creative.example.com` can share an IP and port).
Spinner only reads the handshake, then pipes bytes, so it never touches the encrypted session and works
with online-mode servers, mods and any client version.
```
go build -o spinner ./cmd/spinner
cp spinner.example.json spinner.json
cp .env.example .env # set SPINNER_ADMIN_TOKEN
./spinner -config spinner.json
```
## What it does
| Situation | Server list | Login |
| --- | --- | --- |
| Host matches a server, backend up | cached real MOTD, version and player count (refreshed every `probe_interval_secs`) | proxied |
| Host matches, backend unreachable | that server's `offline_motd` | disconnect with `offline_motd` |
| Host matches no server | `unknown_motd` | disconnect with `unknown_motd` |
| Server at `max_connections`, or IP at `max_conns_per_ip` | `Server is full` / closed | `Server is full` / closed |
- **Routing**: exact domains win; `*.example.com` matches any subdomain depth but not the apex; longer wildcards win. Matching ignores case, a trailing dot and Forge's `\0FML\0` suffix.
- **Failover**: a server may list several `addresses`; they are tried in order.
- **PROXY protocol v2**: set `send_proxy_protocol` per server so the backend sees real player IPs (the backend must be configured to expect it).
- **Hot reload**: edit the config file (or use the admin UI) and it applies within 2 seconds. Invalid edits are logged and ignored. `bind` and `admin_bind` need a restart.
## Config
JSON, see [spinner.example.json](spinner.example.json). Top level: `bind`, `admin_bind` (empty disables the admin),
`handshake_timeout_secs`, `connect_timeout_secs`, `probe_interval_secs`, `max_conns_per_ip`, `unknown_motd`, `servers`.
Per server: `name`, `domains`, `addresses`, `send_proxy_protocol`, `offline_motd`, `max_connections`.
MOTDs accept `§` colour codes and `\n`.
## Admin
With `admin_bind` and `SPINNER_ADMIN_TOKEN` set, `http://<admin_bind>/` shows servers, health, connections and traffic,
and edits the config. Bind it to localhost or a private network and put TLS in front if it must be public.
| Endpoint | |
| --- | --- |
| `GET /api/overview` | totals plus per-server health and stats |
| `GET`/`PUT /api/config` | read or replace the config (validated, written atomically) |
| `GET /metrics` | Prometheus text format |
All except `/` need `Authorization: Bearer <token>`.
## Docker
```
cp .env.example .env # set SPINNER_ADMIN_TOKEN
docker compose -f deploy/docker-compose.yml up -d
```
The image is `git.veillax.com/veillax/spinner` (linux/amd64 and linux/arm64). It keeps its config in the `/data` volume and writes a starter `spinner.json` there on first start, so the admin page on port 8080 is the quickest way to add servers. Keep that port private or behind HTTPS.
## Releases
Push a tag to build archives, publish a release and push the image:
```
git tag -a v0.1.0 -m "spinner v0.1.0" && git push origin v0.1.0
```
`.gitea/workflows/release.yml` runs `deploy/build.sh`, uploads the archives with `deploy/publish-release.sh`, then builds and pushes the image. Set the repository variable `PUBLISH_DOCKER` to `false` to skip the image. If the job token cannot write releases or packages, add the secrets `RELEASE_TOKEN` and `REGISTRY_TOKEN` (and the variable `REGISTRY_USER`).
To build by hand: `sh deploy/build.sh` puts binaries and archives in `dist/`, then `docker buildx build .` packages the linux binary.
## Not in v1
Bedrock Edition, legacy (pre-1.7) pings, up/down webhooks, self-update and WireGuard backends
(Reef has `selfupdate` and `wg` ready for these), and ending player sessions on config reload (existing
connections keep running until they close).
## Development
```
go vet ./... && go test ./...
node scripts/lint-design.mjs
```
Reef is fetched from the server as `git.veillax.com/Veillax/reef`; set `GOPRIVATE=git.veillax.com` if it is private. To work against a local Reef checkout, use a `go.work` file (ignored by git).
The admin page follows the Lightwell design language, see [docs/design.md](docs/design.md).
Licence: AGPL-3.0.