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

+2
View File
@@ -0,0 +1,2 @@
*
!dist/bin
+3
View File
@@ -0,0 +1,3 @@
# Bearer token for the admin UI/API and /metrics (admin is disabled if empty)
SPINNER_ADMIN_TOKEN=change-me
# SPINNER_CONFIG=spinner.json
+53
View File
@@ -0,0 +1,53 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
env:
GOPRIVATE: git.veillax.com
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
# Go fetches Reef straight from this server. This is harmless for public
# repositories and is what makes private ones work.
- name: Let Go fetch modules from this server
run: git config --global url."https://x-access-token:${{ secrets.GITHUB_TOKEN }}@git.veillax.com/".insteadOf "https://git.veillax.com/"
- name: Formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "These files need gofmt:"
echo "$unformatted"
exit 1
fi
- name: Shell scripts parse
run: |
for f in deploy/*.sh; do sh -n "$f"; done
- name: Design language
run: node scripts/lint-design.mjs
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
# Catches platform-specific breakage before a release is tagged.
- name: Every release platform compiles
run: |
for t in linux/amd64 linux/arm64 windows/amd64; do
echo "building for $t"
GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/spinner
done
+62
View File
@@ -0,0 +1,62 @@
name: Release
# Push a tag such as v0.2.0 to build and publish a release:
# git tag -a v0.2.0 -m "spinner v0.2.0" && git push origin v0.2.0
on:
push:
tags: ['v*']
jobs:
release:
runs-on: ubuntu-latest
env:
GOPRIVATE: git.veillax.com
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # release notes come from the history since the previous tag
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Let Go fetch modules from this server
run: git config --global url."https://x-access-token:${{ secrets.GITHUB_TOKEN }}@git.veillax.com/".insteadOf "https://git.veillax.com/"
- name: Test
run: |
go vet ./...
go test ./...
- name: Build release archives
run: sh deploy/build.sh "${GITHUB_REF_NAME}"
# Optional secret RELEASE_TOKEN: an access token that can write releases, for
# servers where the automatic job token can't.
- name: Publish the release
env:
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
run: sh deploy/publish-release.sh "${GITHUB_REF_NAME}"
# Skipped when the repository variable PUBLISH_DOCKER is "false", or fails alone
# if this runner has no Docker: the release above is already published by then.
# Optional secret REGISTRY_TOKEN (and variable REGISTRY_USER): an access token
# with package write permission, for servers where the job token can't push.
- name: Build and push the container image
if: ${{ vars.PUBLISH_DOCKER != 'false' }}
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}
REGISTRY_USER: ${{ vars.REGISTRY_USER || github.actor }}
run: |
host=${GITHUB_SERVER_URL#*://}
owner=$(echo "$GITHUB_REPOSITORY_OWNER" | tr 'A-Z' 'a-z')
image="$host/$owner/spinner"
echo "$REGISTRY_TOKEN" | docker login "$host" -u "$REGISTRY_USER" --password-stdin
# a container-driver builder can build several architectures in one go
docker buildx create --name release --driver docker-container --use >/dev/null 2>&1 || docker buildx use release
tags="-t $image:$GITHUB_REF_NAME"
case "$GITHUB_REF_NAME" in
*-*) ;; # pre-releases (v1.0.0-rc1) don't move :latest
*) tags="$tags -t $image:latest" ;;
esac
docker buildx build --platform linux/amd64,linux/arm64 $tags --push .
+6
View File
@@ -0,0 +1,6 @@
.env
spinner.exe
/spinner
/dist
go.work
go.work.sum
+20
View File
@@ -0,0 +1,20 @@
# syntax=docker/dockerfile:1
# Packages the binary deploy/build.sh already built (dist/bin/linux_<arch>/spinner),
# so the image needs no Go toolchain and no emulation. Build it with:
# sh deploy/build.sh && docker buildx build .
FROM --platform=$BUILDPLATFORM alpine:3.22 AS prep
RUN mkdir /data && chown 65532:65532 /data
FROM scratch
ARG TARGETARCH
LABEL org.opencontainers.image.title="Spinner" \
org.opencontainers.image.description="Minecraft hostname-routing reverse proxy" \
org.opencontainers.image.source="https://git.veillax.com/Veillax/Spinner" \
org.opencontainers.image.licenses="AGPL-3.0-only"
COPY --from=prep --chown=65532:65532 /data /data
COPY dist/bin/linux_${TARGETARCH}/spinner /spinner
USER 65532:65532
VOLUME ["/data"]
EXPOSE 25565 8080
ENTRYPOINT ["/spinner"]
CMD ["-config", "/data/spinner.json"]
+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.
+114
View File
@@ -0,0 +1,114 @@
// Command spinner routes Minecraft Java connections to backend servers by hostname.
package main
import (
"context"
"errors"
"flag"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.veillax.com/Veillax/reef/dotenv"
"git.veillax.com/Veillax/spinner/internal/admin"
"git.veillax.com/Veillax/spinner/internal/config"
"git.veillax.com/Veillax/spinner/internal/health"
"git.veillax.com/Veillax/spinner/internal/proxy"
)
// version is set at build time (deploy/build.sh).
var version = "dev"
const starterConfig = `{
"bind": ":25565",
"admin_bind": ":8080",
"servers": []
}
`
func main() {
envFile := dotenv.ArgPath(os.Args[1:], ".env")
if _, err := dotenv.Load(envFile); err != nil {
slog.Error("reading env file", "err", err)
os.Exit(1)
}
cfgPath := flag.String("config", envOr("SPINNER_CONFIG", "spinner.json"), "config file")
flag.String("env", ".env", "env file with SPINNER_ADMIN_TOKEN")
debug := flag.Bool("debug", false, "verbose logging")
showVersion := flag.Bool("version", false, "print the version and exit")
flag.Parse()
if *showVersion {
os.Stdout.WriteString("spinner " + version + "\n")
return
}
level := slog.LevelInfo
if *debug {
level = slog.LevelDebug
}
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))
// First run, for example in a fresh container volume: write a starter config so the admin page can fill it in.
if _, err := os.Stat(*cfgPath); errors.Is(err, os.ErrNotExist) {
if err := os.WriteFile(*cfgPath, []byte(starterConfig), 0o644); err != nil {
log.Error("writing starter config", "path", *cfgPath, "err", err)
os.Exit(1)
}
log.Info("wrote a starter config", "path", *cfgPath)
}
mgr, err := config.NewManager(*cfgPath, log)
if err != nil {
log.Error("loading config", "err", err)
os.Exit(1)
}
cfg := mgr.Current()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
tracker := health.New(log)
p := proxy.New(mgr, tracker, log)
go mgr.Watch(ctx)
go tracker.Run(ctx, mgr)
if cfg.AdminBind != "" {
token := os.Getenv("SPINNER_ADMIN_TOKEN")
if token == "" {
log.Warn("admin_bind is set but SPINNER_ADMIN_TOKEN is empty; admin UI disabled")
} else {
a := &admin.Admin{Cfg: mgr, Health: tracker, Proxy: p, Token: token}
srv := &http.Server{Addr: cfg.AdminBind, Handler: a.Handler(), ReadHeaderTimeout: 5 * time.Second}
go func() {
log.Info("admin listening", "addr", cfg.AdminBind)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Error("admin server", "err", err)
}
}()
go func() { <-ctx.Done(); srv.Close() }()
}
}
ln, err := net.Listen("tcp", cfg.Bind)
if err != nil {
log.Error("listen", "addr", cfg.Bind, "err", err)
os.Exit(1)
}
log.Info("spinner listening", "addr", cfg.Bind, "servers", len(cfg.Servers))
if err := p.Serve(ctx, ln); err != nil {
log.Error("serve", "err", err)
os.Exit(1)
}
log.Info("shutting down")
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
+47
View File
@@ -0,0 +1,47 @@
#!/bin/sh
# Builds spinner for every release platform and packs the archives into dist/.
#
# deploy/build.sh [version] version defaults to `git describe`
#
# Output:
# dist/bin/<os>_<arch>/spinner the raw binaries (the Docker image uses the linux ones)
# dist/spinner_<version>_<os>_<arch>.tar.gz or .zip
# dist/SHA256SUMS
#
# Releases are published for linux/amd64, linux/arm64 and windows/amd64. Set TARGETS="darwin/arm64"
# (any GOOS/GOARCH pair Go supports) to build something else by hand.
set -eu
NAME=spinner
cd "$(dirname "$0")/.."
VERSION=${1:-$(git describe --tags --always 2>/dev/null || echo dev)}
TARGETS=${TARGETS:-"linux/amd64 linux/arm64 windows/amd64"}
rm -rf dist
mkdir -p dist/bin dist/stage
for t in $TARGETS; do
os=${t%/*}
arch=${t#*/}
ext=""
[ "$os" = windows ] && ext=".exe"
out="dist/bin/${os}_${arch}"
mkdir -p "$out"
echo "building $NAME $VERSION for $os/$arch"
CGO_ENABLED=0 GOOS=$os GOARCH=$arch GOARM=7 \
go build -trimpath -ldflags "-s -w -X main.version=$VERSION" -o "$out/$NAME$ext" "./cmd/$NAME"
pkg="${NAME}_${VERSION}_${os}_${arch}"
stage="dist/stage/$pkg"
mkdir -p "$stage"
cp "$out/$NAME$ext" "$stage/"
for f in LICENSE README.md .env.example spinner.example.json; do
[ -f "$f" ] && cp "$f" "$stage/"
done
if [ "$os" = windows ] && command -v zip >/dev/null 2>&1; then
(cd dist/stage && zip -qr "../$pkg.zip" "$pkg")
else
tar -C dist/stage -czf "dist/$pkg.tar.gz" "$pkg"
fi
done
rm -rf dist/stage
(cd dist && sha256sum $(ls | grep -E '\.(tar\.gz|zip)$') > SHA256SUMS)
echo "done: $(ls dist | grep -cE '\.(tar\.gz|zip)$') archives in dist/"
+19
View File
@@ -0,0 +1,19 @@
# Run Spinner with Docker:
# cp .env.example .env (then set SPINNER_ADMIN_TOKEN)
# docker compose up -d
#
# On first start Spinner writes a starter config to /data/spinner.json. Add servers from the
# admin page on port 8080, or edit the file in the volume. Keep 8080 private or behind HTTPS.
services:
spinner:
image: git.veillax.com/veillax/spinner:latest
restart: unless-stopped
env_file: .env
ports:
- "25565:25565"
- "127.0.0.1:8080:8080"
volumes:
- spinner-data:/data
volumes:
spinner-data:
+71
View File
@@ -0,0 +1,71 @@
#!/bin/sh
# Creates (or updates) the Gitea release for a tag and uploads what deploy/build.sh made.
#
# TOKEN=<access token that can write releases> deploy/publish-release.sh v0.2.0
#
# In Gitea Actions GITHUB_SERVER_URL and GITHUB_REPOSITORY are already set. Safe to
# re-run: an existing release is reused and assets with the same name are replaced.
set -eu
TAG=${1:?usage: publish-release.sh <tag>}
: "${TOKEN:?set TOKEN to a Gitea access token that can write releases}"
SERVER=${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-https://git.veillax.com}}
REPO=${GITHUB_REPOSITORY:-Veillax/Spinner}
API="$SERVER/api/v1/repos/$REPO"
AUTH="Authorization: token $TOKEN"
# a JSON helper without needing jq: any python will do
PY=python3
python3 -c 'pass' > /dev/null 2>&1 || PY=python
json() { "$PY" -c "$1"; }
# release notes: the commit subjects since the previous tag
prev=$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || true)
if [ -n "$prev" ]; then
notes=$(git log --pretty='format:- %s' "$prev..$TAG")
else
notes=$(git log --pretty='format:- %s' "$TAG")
fi
prerelease=false
case "$TAG" in *-*) prerelease=true ;; esac
body=$(NOTES="$notes" TAG="$TAG" PRE="$prerelease" json '
import json, os
print(json.dumps({"tag_name": os.environ["TAG"], "name": os.environ["TAG"], "body": os.environ["NOTES"],
"draft": False, "prerelease": os.environ["PRE"] == "true"}))')
# reuse the release when a failed job is re-run
id=$(curl -fsS -H "$AUTH" "$API/releases/tags/$TAG" 2>/dev/null | json 'import sys, json; print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
if [ -z "$id" ]; then
echo "creating release $TAG"
id=$(curl -fsS -X POST -H "$AUTH" -H 'Content-Type: application/json' -d "$body" "$API/releases" | json 'import sys, json; print(json.load(sys.stdin)["id"])')
else
echo "release $TAG already exists (id $id): replacing its files"
fi
# the ids of the release's assets with a given name (a failed upload can leave a partial one behind)
asset_ids() {
curl -fsS -H "$AUTH" "$API/releases/$id/assets" | "$PY" -c 'import sys, json
for a in json.load(sys.stdin):
if a["name"] == sys.argv[1]:
print(a["id"])' "$1"
}
for f in dist/*.tar.gz dist/*.zip dist/SHA256SUMS; do
[ -f "$f" ] || continue
name=$(basename "$f")
n=1
while :; do
for aid in $(asset_ids "$name"); do
curl -fsS -X DELETE -H "$AUTH" "$API/releases/$id/assets/$aid" > /dev/null
done
echo "uploading $name (attempt $n)"
if curl -fsS --max-time 1200 -X POST -H "$AUTH" -F "attachment=@$f" "$API/releases/$id/assets?name=$name" > /dev/null; then
break
fi
# large uploads can hit a proxy timeout (HTTP 502); try again
[ "$n" -lt 5 ] || { echo "giving up on $name after $n attempts" >&2; exit 1; }
n=$((n + 1))
sleep $((n * 10))
done
done
echo "published $SERVER/$REPO/releases/tag/$TAG"
+62
View File
@@ -0,0 +1,62 @@
# Spinner design language
The admin page follows the Lightwell design language. The reference is `docs/design.md` in the Lightwell repository, and Hammerhead's `docs/design.md` records how it is applied to a dashboard. This page records what Spinner takes from them and where it needs its own pieces. When they disagree, Lightwell's document wins.
## What is shared with Lightwell
* **Tokens.** Colors, spacing (4px scale), radius (6px and 10px), type sizes (12 to 28px), the single shadow and the dark theme are the Lightwell values. The accent is Lightwell's teal, `#1f5c5c`, with white text on it. Everything derived from the accent uses `color-mix`.
* **Principles.** Quiet neutrals with one accent, plain and specific wording, one way to do each thing, calm density, and WCAG 2.1 AA.
* **Voice.** No emoji, no em or en dashes, no exclamation marks, sentence case, buttons that name the result, and errors that say what happened and what to do next. Errors that come from the server are capitalized in one place (`cap` in `api`).
* **Status carries text.** A status is a badge with a label ("Online", "Offline", "Checking"), never a colored dot on its own.
## Where it lives
| File | What it holds |
| --- | --- |
| `web/index.html` | The whole admin page: sign in, Servers and Settings. The token block sits between `tokens:begin` and `tokens:end`; raw colors are allowed only there. |
It is a single file with no build step, so the token block is a copy of Hammerhead's. If Lightwell changes a token, change it in every repository.
## Components
Built from the helpers at the top of the script in `web/index.html`. Feature code uses these and does not style one-off.
| Helper | Use |
| --- | --- |
| `card`, `stat`, `pageHeader` | Surfaces, the numbers above the server list, and the page title with its one primary action |
| `field`, `chk` | Label above the control, hint below |
| `badge(text, tone)` | Status pill. Tones: success, warning, danger, accent, or none for neutral |
| `emptyState(icon, title, text, action)` | Dashed outline, icon, a title that says "No X yet", one sentence |
| `modal`, `dialogForm` | Dialogs. Focus moves in, Tab stays inside, Escape closes, focus returns to the opener. Errors from the server show inside the dialog |
| `confirmDialog`, `ask` | Every destructive action. The title names the object, the body says what is lost and whether it can be undone |
| `toast` | Confirmations at the bottom right. Errors stay longer and are announced as alerts |
| `skeleton` | Placeholder rows while a page loads, not a spinner |
## Icons
Lucide style, outline only, stroke 1.75, round caps, `currentColor`. Sizes are 16, 18 and 22 pixels. They are inline in the `ICONS` table. An icon goes with a text label, and icon-only buttons carry an `aria-label`.
Approved names: `spinner`, `servers`, `settings`, `lock`, `out`, `menu`, `close`, `add`, `check`, `light`, `dark`, `alert`. Add a name here and in `ICONS` together.
## Patterns
* **One primary action per view.** It sits in the page header ("Add server"). Smaller forms inside a page, such as Settings, use the default button.
* **List pages** show the page header, the numbers, then the table in a flush card with no second title.
* **Dialogs and forms** end with Cancel and then the primary action, at the bottom right. Labels sit above fields, and rules such as "One per line" are hints below them.
* **Empty states** say why the list is empty and offer the action that fills it.
* **Long machine messages**, such as a refused connection, wrap and stop at three lines (`.clip`), with the full text in a tooltip.
* **Words.** Players "use an address" to reach a server. The page says "backend" for the machine Spinner forwards to and avoids proxy jargon outside the PROXY protocol option.
## Layout
* Content is capped at 1120px. Below 860px the sidebar becomes a slide-in drawer with a top bar, and grids collapse. Phones keep a 16px side gutter.
* On touch screens controls are at least 44px high and inputs are 16px so the page does not zoom.
* Motion is minimal and turns off under `prefers-reduced-motion`. The theme follows the system and can be switched from the sidebar.
## Checking a change
```
node scripts/lint-design.mjs
```
It fails on em and en dashes, emoji, and raw colors outside the token block, in the page, the docs and the Go source. Then check the page in light and dark, at phone and desktop width, using only the keyboard.
+5
View File
@@ -0,0 +1,5 @@
module git.veillax.com/Veillax/spinner
go 1.27.3
require git.veillax.com/Veillax/reef v0.1.7
+2
View File
@@ -0,0 +1,2 @@
git.veillax.com/Veillax/reef v0.1.7 h1:5C2udonlJac2xvDOE5e+zDxej+AbZ9ttrNSE4owmwuU=
git.veillax.com/Veillax/reef v0.1.7/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
+140
View File
@@ -0,0 +1,140 @@
// Package admin serves the REST API, Prometheus metrics and the embedded UI.
package admin
import (
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"git.veillax.com/Veillax/spinner/internal/config"
"git.veillax.com/Veillax/spinner/internal/health"
"git.veillax.com/Veillax/spinner/internal/proxy"
"git.veillax.com/Veillax/spinner/web"
)
type Admin struct {
Cfg *config.Manager
Health *health.Tracker
Proxy *proxy.Proxy
Token string
}
func (a *Admin) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'")
w.Write(web.Index)
})
mux.HandleFunc("GET /api/overview", a.auth(a.overview))
mux.HandleFunc("GET /api/config", a.auth(a.getConfig))
mux.HandleFunc("PUT /api/config", a.auth(a.putConfig))
mux.HandleFunc("GET /metrics", a.auth(a.metrics))
return mux
}
func (a *Admin) auth(next http.HandlerFunc) http.HandlerFunc {
want := sha256.Sum256([]byte(a.Token))
return func(w http.ResponseWriter, r *http.Request) {
got := sha256.Sum256([]byte(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")))
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
w.Header().Set("WWW-Authenticate", `Bearer realm="spinner"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
type serverView struct {
config.Server
Health *health.State `json:"health,omitempty"`
Stats *proxy.ServerSnapshot `json:"stats,omitempty"`
}
func (a *Admin) overview(w http.ResponseWriter, r *http.Request) {
snap := a.Proxy.Stats.Snapshot()
states := map[string]health.State{}
for _, s := range a.Health.Snapshot() {
states[s.Name] = s
}
stats := map[string]proxy.ServerSnapshot{}
for _, s := range snap.Servers {
stats[s.Name] = s
}
var servers []serverView
for _, s := range a.Cfg.Current().Servers {
v := serverView{Server: s}
if h, ok := states[s.Name]; ok {
v.Health = &h
}
if st, ok := stats[s.Name]; ok {
v.Stats = &st
}
servers = append(servers, v)
}
snap.Servers = nil
writeJSON(w, map[string]any{"totals": snap, "servers": servers})
}
func (a *Admin) getConfig(w http.ResponseWriter, r *http.Request) { writeJSON(w, a.Cfg.Current()) }
func (a *Admin) putConfig(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
cfg, err := config.Parse(body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := a.Cfg.Save(cfg); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, cfg)
}
func esc(s string) string {
return strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`).Replace(s)
}
func (a *Admin) metrics(w http.ResponseWriter, r *http.Request) {
snap := a.Proxy.Stats.Snapshot()
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
p := func(format string, args ...any) { fmt.Fprintf(w, format+"\n", args...) }
p("# TYPE spinner_uptime_seconds gauge\nspinner_uptime_seconds %d", snap.UptimeSecs)
p("# TYPE spinner_connections_active gauge\nspinner_connections_active %d", snap.Active)
p("# TYPE spinner_connections_total counter\nspinner_connections_total %d", snap.Total)
p("# TYPE spinner_connections_rejected_total counter\nspinner_connections_rejected_total %d", snap.Rejected)
p("# TYPE spinner_unknown_host_total counter\nspinner_unknown_host_total %d", snap.Unknown)
for _, s := range snap.Servers {
l := fmt.Sprintf(`{server="%s"}`, esc(s.Name))
p("spinner_server_connections_active%s %d", l, s.Active)
p("spinner_server_connections_total%s %d", l, s.Total)
p("spinner_server_failures_total%s %d", l, s.Failures)
p("spinner_server_bytes_in_total%s %d", l, s.BytesIn)
p("spinner_server_bytes_out_total%s %d", l, s.BytesOut)
}
for _, h := range a.Health.Snapshot() {
up := 0
if h.Up {
up = 1
}
l := fmt.Sprintf(`{server="%s"}`, esc(h.Name))
p("spinner_server_up%s %d", l, up)
p("spinner_server_latency_ms%s %d", l, h.LatencyMS)
p("spinner_server_players_online%s %d", l, h.Online)
}
}
+219
View File
@@ -0,0 +1,219 @@
// Package config loads, validates, saves and hot-reloads Spinner's JSON config.
package config
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
)
type Config struct {
Bind string `json:"bind"`
AdminBind string `json:"admin_bind"`
HandshakeTimeoutSecs int `json:"handshake_timeout_secs"`
ConnectTimeoutSecs int `json:"connect_timeout_secs"`
ProbeIntervalSecs int `json:"probe_interval_secs"`
MaxConnsPerIP int `json:"max_conns_per_ip"`
UnknownMOTD string `json:"unknown_motd"`
Servers []Server `json:"servers"`
}
type Server struct {
Name string `json:"name"`
Domains []string `json:"domains"`
Addresses []string `json:"addresses"`
SendProxyProtocol bool `json:"send_proxy_protocol,omitempty"`
OfflineMOTD string `json:"offline_motd,omitempty"`
MaxConnections int `json:"max_connections,omitempty"`
}
func (c *Config) HandshakeTimeout() time.Duration {
return time.Duration(c.HandshakeTimeoutSecs) * time.Second
}
func (c *Config) ConnectTimeout() time.Duration {
return time.Duration(c.ConnectTimeoutSecs) * time.Second
}
func (c *Config) ProbeInterval() time.Duration {
return time.Duration(c.ProbeIntervalSecs) * time.Second
}
// Parse decodes, applies defaults to and validates a config document.
func Parse(data []byte) (*Config, error) {
var c Config
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&c); err != nil {
return nil, err
}
def := func(v *int, d int) {
if *v <= 0 {
*v = d
}
}
if c.Bind == "" {
c.Bind = ":25565"
}
def(&c.HandshakeTimeoutSecs, 5)
def(&c.ConnectTimeoutSecs, 3)
def(&c.ProbeIntervalSecs, 10)
if c.UnknownMOTD == "" {
c.UnknownMOTD = "§cUnknown server"
}
return &c, c.validate()
}
func (c *Config) validate() error {
seen := map[string]bool{}
for i := range c.Servers {
s := &c.Servers[i]
if s.Name == "" {
return fmt.Errorf("server %d has no name. Give every server a name", i+1)
}
if seen[s.Name] {
return fmt.Errorf("two servers are named %q. Names must be different", s.Name)
}
seen[s.Name] = true
if len(s.Domains) == 0 {
return fmt.Errorf("server %q has no addresses for players. Add at least one", s.Name)
}
if len(s.Addresses) == 0 {
return fmt.Errorf("server %q has no backend address. Add at least one as host:port", s.Name)
}
for _, a := range s.Addresses {
if _, _, err := net.SplitHostPort(a); err != nil {
return fmt.Errorf("server %q: backend address %q is not host:port (%v)", s.Name, a, err)
}
}
for _, d := range s.Domains {
if strings.TrimSpace(d) == "" || strings.Contains(strings.TrimPrefix(d, "*."), "*") {
return fmt.Errorf("server %q: %q is not a valid address for players. Use a name like play.example.com or *.example.com", s.Name, d)
}
}
}
return nil
}
// Manager owns the current config, persists changes and reloads on file edits.
type Manager struct {
path string
cur atomic.Pointer[Config]
mu sync.Mutex // guards raw and subs
raw []byte
subs []func(*Config)
log *slog.Logger
}
func NewManager(path string, log *slog.Logger) (*Manager, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
c, err := Parse(data)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
m := &Manager{path: path, raw: data, log: log}
m.cur.Store(c)
return m, nil
}
func (m *Manager) Current() *Config { return m.cur.Load() }
// OnChange registers a callback invoked after every successful reload or save.
func (m *Manager) OnChange(f func(*Config)) {
m.mu.Lock()
m.subs = append(m.subs, f)
m.mu.Unlock()
}
func (m *Manager) apply(data []byte, c *Config) {
m.mu.Lock()
m.raw = data
subs := append([]func(*Config){}, m.subs...)
m.mu.Unlock()
m.cur.Store(c)
for _, f := range subs {
f(c)
}
}
// Save validates c, writes it atomically and applies it immediately.
func (m *Manager) Save(c *Config) error {
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
parsed, err := Parse(data)
if err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(m.path), ".spinner-*.json")
if err != nil {
return err
}
_, werr := tmp.Write(append(data, '\n'))
cerr := tmp.Close()
if err := errors.Join(werr, cerr); err != nil {
os.Remove(tmp.Name())
return err
}
if err := os.Rename(tmp.Name(), m.path); err != nil {
os.Remove(tmp.Name())
return err
}
m.mu.Lock()
m.raw = append(data, '\n')
m.mu.Unlock()
m.apply(m.raw, parsed)
return nil
}
func (m *Manager) reloadIfChanged() {
data, err := os.ReadFile(m.path)
if err != nil {
return
}
m.mu.Lock()
same := bytes.Equal(data, m.raw)
m.mu.Unlock()
if same {
return
}
c, err := Parse(data)
if err != nil {
m.log.Error("config reload rejected, keeping previous config", "err", err)
m.mu.Lock()
m.raw = data // don't repeat the error until the file changes again
m.mu.Unlock()
return
}
if old := m.Current(); old.Bind != c.Bind || old.AdminBind != c.AdminBind {
m.log.Warn("bind address changes need a restart")
}
m.apply(data, c)
m.log.Info("config reloaded", "servers", len(c.Servers))
}
// Watch polls the file until ctx ends.
func (m *Manager) Watch(ctx context.Context) {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
m.reloadIfChanged()
}
}
}
+154
View File
@@ -0,0 +1,154 @@
// Package health probes backends with a status ping, caching their server-list
// response and tracking whether each one is up.
package health
import (
"context"
"encoding/json"
"log/slog"
"net"
"sort"
"sync"
"time"
"git.veillax.com/Veillax/spinner/internal/config"
"git.veillax.com/Veillax/spinner/internal/protocol"
)
type State struct {
Name string `json:"name"`
Up bool `json:"up"`
LastCheck time.Time `json:"last_check"`
LatencyMS int64 `json:"latency_ms"`
Error string `json:"error,omitempty"`
Version string `json:"version,omitempty"`
Online int `json:"online"`
Max int `json:"max"`
status string
}
type Tracker struct {
mu sync.RWMutex
st map[string]*State
log *slog.Logger
}
func New(log *slog.Logger) *Tracker {
return &Tracker{st: map[string]*State{}, log: log}
}
// Cached returns the backend's last status JSON if it is up and the probe is
// no older than maxAge.
func (t *Tracker) Cached(name string, maxAge time.Duration) (string, bool) {
t.mu.RLock()
defer t.mu.RUnlock()
s := t.st[name]
if s == nil || !s.Up || s.status == "" || time.Since(s.LastCheck) > maxAge {
return "", false
}
return s.status, true
}
func (t *Tracker) Snapshot() []State {
t.mu.RLock()
defer t.mu.RUnlock()
out := make([]State, 0, len(t.st))
for _, s := range t.st {
out = append(out, *s)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Run probes every server immediately and then on each interval until ctx ends.
func (t *Tracker) Run(ctx context.Context, m *config.Manager) {
for {
cfg := m.Current()
t.probeAll(ctx, cfg)
select {
case <-ctx.Done():
return
case <-time.After(cfg.ProbeInterval()):
}
}
}
func (t *Tracker) probeAll(ctx context.Context, cfg *config.Config) {
var wg sync.WaitGroup
for i := range cfg.Servers {
wg.Add(1)
go func(s *config.Server) {
defer wg.Done()
t.record(s.Name, Probe(ctx, s, cfg.ConnectTimeout()))
}(&cfg.Servers[i])
}
wg.Wait()
names := map[string]bool{}
for _, s := range cfg.Servers {
names[s.Name] = true
}
t.mu.Lock()
for n := range t.st {
if !names[n] {
delete(t.st, n)
}
}
t.mu.Unlock()
}
// Result is the outcome of one probe.
type Result struct {
Status string
Latency time.Duration
Err error
}
// Probe tries each of the server's addresses in order and returns the first success.
func Probe(ctx context.Context, s *config.Server, timeout time.Duration) Result {
var last Result
for _, addr := range s.Addresses {
start := time.Now()
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
last = Result{Err: err}
continue
}
host, port := protocol.SplitHostPort(addr)
js, err := protocol.QueryStatus(conn, host, port, timeout)
conn.Close()
if err != nil {
last = Result{Err: err}
continue
}
return Result{Status: js, Latency: time.Since(start)}
}
return last
}
func (t *Tracker) record(name string, r Result) {
s := State{Name: name, Up: r.Err == nil, LastCheck: time.Now(), LatencyMS: r.Latency.Milliseconds(), status: r.Status}
if r.Err != nil {
s.Error = r.Err.Error()
} else {
var parsed struct {
Version struct{ Name string } `json:"version"`
Players struct{ Online, Max int } `json:"players"`
}
if json.Unmarshal([]byte(r.Status), &parsed) == nil {
s.Version, s.Online, s.Max = parsed.Version.Name, parsed.Players.Online, parsed.Players.Max
}
}
t.mu.Lock()
prev := t.st[name]
t.st[name] = &s
t.mu.Unlock()
if prev == nil || prev.Up != s.Up {
if s.Up {
t.log.Info("backend up", "server", name, "latency_ms", s.LatencyMS)
} else {
t.log.Warn("backend down", "server", name, "err", s.Error)
}
}
}
+215
View File
@@ -0,0 +1,215 @@
// Package protocol implements the small slice of the Minecraft Java protocol
// Spinner needs: the handshake, the status exchange and the login disconnect.
// Everything after the handshake is passed through untouched.
package protocol
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"strconv"
"time"
)
const (
maxHandshake = 1024
maxStatus = 1 << 20
)
// ErrLegacy is returned for pre-1.7 server list pings, which Spinner does not route.
var ErrLegacy = errors.New("legacy ping")
type Handshake struct {
Protocol int32
Host string
Port uint16
NextState int32 // 1 status, 2 login, 3 transfer
}
func (h Handshake) IsStatus() bool { return h.NextState == 1 }
func readVarInt(r io.Reader, raw *[]byte) (int32, error) {
var v uint32
var b [1]byte
for i := 0; i < 5; i++ {
if _, err := io.ReadFull(r, b[:]); err != nil {
return 0, err
}
if raw != nil {
*raw = append(*raw, b[0])
}
v |= uint32(b[0]&0x7f) << (7 * i)
if b[0]&0x80 == 0 {
return int32(v), nil
}
}
return 0, errors.New("varint too long")
}
func AppendVarInt(buf []byte, v int32) []byte {
u := uint32(v)
for u >= 0x80 {
buf = append(buf, byte(u)|0x80)
u >>= 7
}
return append(buf, byte(u))
}
func appendString(buf []byte, s string) []byte {
buf = AppendVarInt(buf, int32(len(s)))
return append(buf, s...)
}
// Frame prefixes a packet body (id + payload) with its length.
func Frame(body []byte) []byte {
return append(AppendVarInt(make([]byte, 0, len(body)+5), int32(len(body))), body...)
}
// ReadHandshake reads one handshake packet and returns it along with the exact
// bytes consumed, so they can be replayed to the backend unchanged.
func ReadHandshake(r io.Reader) (Handshake, []byte, error) {
var raw []byte
n, err := readVarInt(r, &raw)
if err != nil {
return Handshake{}, raw, err
}
if len(raw) > 0 && raw[0] == 0xFE {
return Handshake{}, raw, ErrLegacy
}
if n <= 0 || n > maxHandshake {
return Handshake{}, raw, fmt.Errorf("invalid handshake length %d", n)
}
body := make([]byte, n)
if _, err := io.ReadFull(r, body); err != nil {
return Handshake{}, raw, err
}
raw = append(raw, body...)
br := bytes.NewReader(body)
id, err := readVarInt(br, nil)
if err != nil || id != 0 {
return Handshake{}, raw, errors.New("expected handshake packet")
}
var h Handshake
if h.Protocol, err = readVarInt(br, nil); err != nil {
return Handshake{}, raw, err
}
hl, err := readVarInt(br, nil)
if err != nil || hl < 0 || int(hl) > br.Len()-3 {
return Handshake{}, raw, errors.New("invalid host length")
}
host := make([]byte, hl)
io.ReadFull(br, host)
h.Host = string(host)
var port [2]byte
io.ReadFull(br, port[:])
h.Port = uint16(port[0])<<8 | uint16(port[1])
if h.NextState, err = readVarInt(br, nil); err != nil {
return Handshake{}, raw, err
}
return h, raw, nil
}
func BuildHandshake(protocol int32, host string, port uint16, next int32) []byte {
body := AppendVarInt(nil, 0)
body = AppendVarInt(body, protocol)
body = appendString(body, host)
body = append(body, byte(port>>8), byte(port))
body = AppendVarInt(body, next)
return Frame(body)
}
// ReadPacket reads one length-prefixed packet and returns its id and payload.
func ReadPacket(r io.Reader, max int32) (int32, []byte, error) {
n, err := readVarInt(r, nil)
if err != nil {
return 0, nil, err
}
if n <= 0 || n > max {
return 0, nil, fmt.Errorf("invalid packet length %d", n)
}
body := make([]byte, n)
if _, err := io.ReadFull(r, body); err != nil {
return 0, nil, err
}
br := bytes.NewReader(body)
id, err := readVarInt(br, nil)
if err != nil {
return 0, nil, err
}
return id, body[len(body)-br.Len():], nil
}
func stringPacket(id int32, s string) []byte {
return Frame(appendString(AppendVarInt(nil, id), s))
}
// StatusJSON builds a server list response.
func StatusJSON(clientProtocol int32, version, motd string, online, max int) string {
b, _ := json.Marshal(map[string]any{
"version": map[string]any{"name": version, "protocol": clientProtocol},
"players": map[string]any{"max": max, "online": online},
"description": map[string]any{"text": motd},
})
return string(b)
}
// LoginDisconnect is the packet a client shows as "Disconnected: <message>".
func LoginDisconnect(message string) []byte {
b, _ := json.Marshal(map[string]string{"text": message})
return stringPacket(0, string(b))
}
// ServeStatus plays the server side of the status exchange with a canned
// response: request, response, then an echoed ping if the client sends one.
func ServeStatus(rw io.ReadWriter, statusJSON string) error {
if id, _, err := ReadPacket(rw, 16); err != nil || id != 0 {
return errors.New("bad status request")
}
if _, err := rw.Write(stringPacket(0, statusJSON)); err != nil {
return err
}
id, payload, err := ReadPacket(rw, 32)
if err != nil || id != 1 {
return nil // client closed after the response
}
_, err = rw.Write(Frame(append(AppendVarInt(nil, 1), payload...)))
return err
}
// QueryStatus performs a status ping against a backend and returns the raw JSON.
func QueryStatus(conn net.Conn, host string, port uint16, timeout time.Duration) (string, error) {
conn.SetDeadline(time.Now().Add(timeout))
if _, err := conn.Write(BuildHandshake(767, host, port, 1)); err != nil {
return "", err
}
if _, err := conn.Write(Frame([]byte{0})); err != nil {
return "", err
}
id, payload, err := ReadPacket(conn, maxStatus)
if err != nil {
return "", err
}
if id != 0 {
return "", fmt.Errorf("unexpected packet id %d", id)
}
br := bytes.NewReader(payload)
n, err := readVarInt(br, nil)
if err != nil || n < 0 || int(n) > br.Len() {
return "", errors.New("bad status string")
}
return string(payload[len(payload)-br.Len() : len(payload)-br.Len()+int(n)]), nil
}
// SplitHostPort parses a backend "host:port" address.
func SplitHostPort(addr string) (string, uint16) {
h, p, err := net.SplitHostPort(addr)
if err != nil {
return addr, 25565
}
n, _ := strconv.Atoi(p)
return h, uint16(n)
}
+87
View File
@@ -0,0 +1,87 @@
package protocol
import (
"bytes"
"net"
"testing"
"time"
)
func TestHandshakeRoundTrip(t *testing.T) {
in := BuildHandshake(767, "mc.example.com", 25565, 2)
h, raw, err := ReadHandshake(bytes.NewReader(in))
if err != nil {
t.Fatal(err)
}
want := Handshake{Protocol: 767, Host: "mc.example.com", Port: 25565, NextState: 2}
if h != want {
t.Fatalf("got %+v want %+v", h, want)
}
if !bytes.Equal(raw, in) {
t.Fatal("raw bytes not preserved")
}
}
func TestReadHandshakeRejects(t *testing.T) {
cases := map[string][]byte{
"legacy": {0xfe, 0x01, 0xfa},
"huge length": {0xff, 0xff, 0xff, 0xff, 0x07},
"zero length": {0x00},
"wrong id": Frame([]byte{0x05, 0x00}),
"short host": Frame([]byte{0x00, 0x01, 0x40, 'a'}),
"truncated": {0x10, 0x00},
}
for name, b := range cases {
if _, _, err := ReadHandshake(bytes.NewReader(b)); err == nil {
t.Errorf("%s: expected error", name)
}
}
if _, _, err := ReadHandshake(bytes.NewReader([]byte{0xfe, 0x01})); err != ErrLegacy {
t.Errorf("want ErrLegacy, got %v", err)
}
}
func TestVarInt(t *testing.T) {
if got := AppendVarInt(nil, 300); !bytes.Equal(got, []byte{0xac, 0x02}) {
t.Fatalf("got %x", got)
}
if got := AppendVarInt(nil, -1); len(got) != 5 {
t.Fatalf("negative should take 5 bytes, got %x", got)
}
}
func TestStatusExchange(t *testing.T) {
c, s := net.Pipe()
defer c.Close()
go func() {
defer s.Close()
ServeStatus(s, `{"ok":true}`)
}()
c.SetDeadline(time.Now().Add(2 * time.Second))
c.Write(Frame([]byte{0}))
id, payload, err := ReadPacket(c, maxStatus)
if err != nil || id != 0 || !bytes.Contains(payload, []byte(`{"ok":true}`)) {
t.Fatalf("id=%d payload=%q err=%v", id, payload, err)
}
c.Write(Frame(append([]byte{1}, 0, 0, 0, 0, 0, 0, 0, 42)))
id, payload, err = ReadPacket(c, 32)
if err != nil || id != 1 || payload[7] != 42 {
t.Fatalf("pong id=%d payload=%x err=%v", id, payload, err)
}
}
func TestQueryStatus(t *testing.T) {
c, s := net.Pipe()
defer c.Close()
go func() {
defer s.Close()
if _, _, err := ReadHandshake(s); err != nil {
return
}
ServeStatus(s, `{"players":{"online":3}}`)
}()
got, err := QueryStatus(c, "x", 25565, 2*time.Second)
if err != nil || got != `{"players":{"online":3}}` {
t.Fatalf("got %q err=%v", got, err)
}
}
+190
View File
@@ -0,0 +1,190 @@
// Package proxy accepts Minecraft connections, routes them by hostname and
// pipes bytes to the chosen backend.
package proxy
import (
"context"
"errors"
"io"
"log/slog"
"net"
"sync"
"sync/atomic"
"time"
"git.veillax.com/Veillax/spinner/internal/config"
"git.veillax.com/Veillax/spinner/internal/health"
"git.veillax.com/Veillax/spinner/internal/protocol"
"git.veillax.com/Veillax/spinner/internal/router"
)
type Proxy struct {
cfg *config.Manager
health *health.Tracker
log *slog.Logger
rt atomic.Pointer[router.Router]
Stats *Stats
ipMu sync.Mutex
ipConns map[string]int
}
func New(cfg *config.Manager, h *health.Tracker, log *slog.Logger) *Proxy {
p := &Proxy{cfg: cfg, health: h, log: log, Stats: newStats(), ipConns: map[string]int{}}
p.rt.Store(router.New(cfg.Current()))
cfg.OnChange(func(c *config.Config) { p.rt.Store(router.New(c)) })
return p
}
// Serve accepts connections until ctx ends or the listener fails.
func (p *Proxy) Serve(ctx context.Context, ln net.Listener) error {
go func() { <-ctx.Done(); ln.Close() }()
for {
c, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return nil
}
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
continue
}
return err
}
go p.handle(c)
}
}
func (p *Proxy) acquireIP(ip string, limit int) bool {
p.ipMu.Lock()
defer p.ipMu.Unlock()
if limit > 0 && p.ipConns[ip] >= limit {
return false
}
p.ipConns[ip]++
return true
}
func (p *Proxy) releaseIP(ip string) {
p.ipMu.Lock()
defer p.ipMu.Unlock()
if p.ipConns[ip]--; p.ipConns[ip] <= 0 {
delete(p.ipConns, ip)
}
}
// reject answers a status ping with a canned MOTD, or a login with a disconnect message.
func (p *Proxy) reject(c net.Conn, h protocol.Handshake, msg string) {
c.SetDeadline(time.Now().Add(10 * time.Second))
if h.IsStatus() {
protocol.ServeStatus(c, protocol.StatusJSON(h.Protocol, "Spinner", msg, 0, 0))
return
}
c.Write(protocol.LoginDisconnect(msg))
}
func (p *Proxy) handle(c net.Conn) {
defer c.Close()
cfg := p.cfg.Current()
p.Stats.total.Add(1)
ip, _, _ := net.SplitHostPort(c.RemoteAddr().String())
if !p.acquireIP(ip, cfg.MaxConnsPerIP) {
p.Stats.rejected.Add(1)
return
}
defer p.releaseIP(ip)
c.SetReadDeadline(time.Now().Add(cfg.HandshakeTimeout()))
hs, raw, err := protocol.ReadHandshake(c)
if err != nil {
p.log.Debug("bad handshake", "remote", ip, "err", err)
return
}
c.SetReadDeadline(time.Time{})
srv := p.rt.Load().Match(hs.Host)
if srv == nil {
p.Stats.unknown.Add(1)
p.log.Debug("unknown host", "remote", ip, "host", hs.Host)
p.reject(c, hs, cfg.UnknownMOTD)
return
}
ss := p.Stats.server(srv.Name)
ss.total.Add(1)
if srv.MaxConnections > 0 && ss.active.Load() >= int64(srv.MaxConnections) {
p.Stats.rejected.Add(1)
p.reject(c, hs, "§cServer is full")
return
}
if hs.IsStatus() {
if js, ok := p.health.Cached(srv.Name, 3*cfg.ProbeInterval()); ok {
c.SetDeadline(time.Now().Add(10 * time.Second))
protocol.ServeStatus(c, js)
return
}
}
b, err := dialAny(srv.Addresses, cfg.ConnectTimeout())
if err != nil {
ss.failures.Add(1)
p.log.Warn("backend unreachable", "server", srv.Name, "err", err)
msg := srv.OfflineMOTD
if msg == "" {
msg = "§c" + srv.Name + " is offline"
}
p.reject(c, hs, msg)
return
}
defer b.Close()
if srv.SendProxyProtocol {
if _, err := b.Write(proxyV2Header(c.RemoteAddr(), c.LocalAddr())); err != nil {
return
}
}
if _, err := b.Write(raw); err != nil {
return
}
p.Stats.active.Add(1)
ss.active.Add(1)
defer func() { p.Stats.active.Add(-1); ss.active.Add(-1) }()
if !hs.IsStatus() {
p.log.Info("connected", "remote", ip, "host", hs.Host, "server", srv.Name)
}
pipe(c, b, ss)
}
func dialAny(addrs []string, timeout time.Duration) (net.Conn, error) {
var errs []error
for _, a := range addrs {
conn, err := net.DialTimeout("tcp", a, timeout)
if err == nil {
return conn, nil
}
errs = append(errs, err)
}
return nil, errors.Join(errs...)
}
// pipe copies both ways and tears both connections down when either side ends.
func pipe(client, backend net.Conn, ss *serverStats) {
done := make(chan struct{}, 2)
go func() {
n, _ := io.Copy(backend, client)
ss.bytesIn.Add(n)
done <- struct{}{}
}()
go func() {
n, _ := io.Copy(client, backend)
ss.bytesOut.Add(n)
done <- struct{}{}
}()
<-done
client.Close()
backend.Close()
<-done
}
+234
View File
@@ -0,0 +1,234 @@
package proxy
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"io"
"log/slog"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.veillax.com/Veillax/spinner/internal/config"
"git.veillax.com/Veillax/spinner/internal/health"
"git.veillax.com/Veillax/spinner/internal/protocol"
)
var quiet = slog.New(slog.NewTextHandler(io.Discard, nil))
// backend is a fake Minecraft server: status pings get a canned response, and
// login connections are answered with "hello from <name>" plus the PROXY header
// it saw, if any.
func backend(t *testing.T, name string, expectProxy bool) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func() {
defer c.Close()
c.SetDeadline(time.Now().Add(5 * time.Second))
seen := "none"
if expectProxy {
hdr := make([]byte, 16)
if _, err := io.ReadFull(c, hdr); err != nil {
return
}
rest := make([]byte, binary.BigEndian.Uint16(hdr[14:]))
io.ReadFull(c, rest)
if bytes.Equal(hdr[:12], v2sig) && hdr[12] == 0x21 && hdr[13] == 0x11 {
seen = fmt.Sprintf("%d.%d.%d.%d", rest[0], rest[1], rest[2], rest[3])
}
}
hs, _, err := protocol.ReadHandshake(c)
if err != nil {
return
}
if hs.IsStatus() {
protocol.ServeStatus(c, `{"version":{"name":"fake","protocol":767},"players":{"online":2,"max":10},"description":{"text":"`+name+`"}}`)
return
}
fmt.Fprintf(c, "hello from %s proxy=%s host=%s", name, seen, hs.Host)
}()
}
}()
return ln.Addr().String()
}
func start(t *testing.T, cfgJSON string) (addr string, p *Proxy, h *health.Tracker) {
t.Helper()
path := filepath.Join(t.TempDir(), "spinner.json")
if err := os.WriteFile(path, []byte(cfgJSON), 0o600); err != nil {
t.Fatal(err)
}
m, err := config.NewManager(path, quiet)
if err != nil {
t.Fatal(err)
}
h = health.New(quiet)
p = New(m, h, quiet)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
go p.Serve(ctx, ln)
return ln.Addr().String(), p, h
}
func login(t *testing.T, addr, host string) string {
t.Helper()
c, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
defer c.Close()
c.SetDeadline(time.Now().Add(5 * time.Second))
c.Write(protocol.BuildHandshake(767, host, 25565, 2))
b, _ := io.ReadAll(c)
return string(b)
}
func status(t *testing.T, addr, host string) string {
t.Helper()
c, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
defer c.Close()
js, err := protocol.QueryStatus(c, host, 25565, 5*time.Second)
if err != nil {
t.Fatal(err)
}
return js
}
func TestRoutesByHostname(t *testing.T) {
a, b := backend(t, "alpha", false), backend(t, "beta", false)
addr, _, _ := start(t, fmt.Sprintf(`{"servers":[
{"name":"alpha","domains":["alpha.test"],"addresses":[%q]},
{"name":"beta","domains":["*.beta.test"],"addresses":[%q]}]}`, a, b))
if got := login(t, addr, "alpha.test"); !strings.HasPrefix(got, "hello from alpha") {
t.Errorf("alpha: %q", got)
}
if got := login(t, addr, "x.beta.test"); !strings.HasPrefix(got, "hello from beta") || !strings.Contains(got, "host=x.beta.test") {
t.Errorf("beta: %q", got)
}
}
func TestProxyProtocolHeader(t *testing.T) {
a := backend(t, "pp", true)
addr, _, _ := start(t, fmt.Sprintf(`{"servers":[{"name":"pp","domains":["pp.test"],"addresses":[%q],"send_proxy_protocol":true}]}`, a))
if got := login(t, addr, "pp.test"); !strings.Contains(got, "proxy=127.0.0.1") {
t.Errorf("backend did not see client address in PROXY header: %q", got)
}
}
func TestFallsBackToSecondAddress(t *testing.T) {
good := backend(t, "good", false)
addr, _, _ := start(t, fmt.Sprintf(`{"servers":[{"name":"s","domains":["s.test"],"addresses":["127.0.0.1:1",%q]}]}`, good))
if got := login(t, addr, "s.test"); !strings.HasPrefix(got, "hello from good") {
t.Errorf("got %q", got)
}
}
func TestOfflineBackend(t *testing.T) {
addr, p, _ := start(t, `{"servers":[{"name":"dead","domains":["dead.test"],"addresses":["127.0.0.1:1"],"offline_motd":"napping"}]}`)
if js := status(t, addr, "dead.test"); !strings.Contains(js, "napping") {
t.Errorf("status: %s", js)
}
if got := login(t, addr, "dead.test"); !strings.Contains(got, "napping") {
t.Errorf("login: %q", got)
}
if f := p.Stats.Snapshot().Servers[0].Failures; f < 2 {
t.Errorf("failures = %d", f)
}
}
func TestUnknownHost(t *testing.T) {
addr, p, _ := start(t, `{"unknown_motd":"nobody home","servers":[]}`)
if js := status(t, addr, "who.test"); !strings.Contains(js, "nobody home") {
t.Errorf("status: %s", js)
}
if got := login(t, addr, "who.test"); !strings.Contains(got, "nobody home") {
t.Errorf("login: %q", got)
}
if p.Stats.Snapshot().Unknown != 2 {
t.Errorf("unknown = %d", p.Stats.Snapshot().Unknown)
}
}
func TestStatusServedFromHealthCache(t *testing.T) {
a := backend(t, "live", false)
addr, p, h := start(t, fmt.Sprintf(`{"probe_interval_secs":60,"servers":[{"name":"live","domains":["live.test"],"addresses":[%q]}]}`, a))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go h.Run(ctx, p.cfg)
deadline := time.Now().Add(3 * time.Second)
for {
if _, ok := h.Cached("live", time.Minute); ok {
break
}
if time.Now().After(deadline) {
t.Fatal("probe never succeeded")
}
time.Sleep(20 * time.Millisecond)
}
if js := status(t, addr, "live.test"); !strings.Contains(js, `"online":2`) {
t.Errorf("status: %s", js)
}
if snap := h.Snapshot(); len(snap) != 1 || !snap[0].Up || snap[0].Online != 2 || snap[0].Version != "fake" {
t.Errorf("snapshot: %+v", snap)
}
}
func TestPerIPLimit(t *testing.T) {
addr, p, _ := start(t, `{"max_conns_per_ip":1,"servers":[]}`)
hold, err := net.Dial("tcp", addr) // never sends a handshake, so it keeps its slot
if err != nil {
t.Fatal(err)
}
defer hold.Close()
time.Sleep(100 * time.Millisecond)
c, _ := net.Dial("tcp", addr)
defer c.Close()
c.SetDeadline(time.Now().Add(2 * time.Second))
if _, err := io.ReadAll(c); err != nil && !strings.Contains(err.Error(), "reset") {
t.Errorf("expected close, got %v", err)
}
if p.Stats.Snapshot().Rejected != 1 {
t.Errorf("rejected = %d", p.Stats.Snapshot().Rejected)
}
}
func TestConfigHotSwap(t *testing.T) {
a := backend(t, "one", false)
addr, p, _ := start(t, `{"servers":[]}`)
if got := login(t, addr, "one.test"); strings.HasPrefix(got, "hello") {
t.Fatalf("should be unknown: %q", got)
}
cfg, err := config.Parse([]byte(fmt.Sprintf(`{"servers":[{"name":"one","domains":["one.test"],"addresses":[%q]}]}`, a)))
if err != nil {
t.Fatal(err)
}
if err := p.cfg.Save(cfg); err != nil {
t.Fatal(err)
}
if got := login(t, addr, "one.test"); !strings.HasPrefix(got, "hello from one") {
t.Errorf("after save: %q", got)
}
}
+31
View File
@@ -0,0 +1,31 @@
package proxy
import (
"encoding/binary"
"net"
)
var v2sig = []byte("\r\n\r\n\x00\r\nQUIT\n")
// proxyV2Header builds a PROXY protocol v2 header carrying the client's real
// address so the backend can see it. Falls back to a LOCAL header if the
// addresses aren't TCP.
func proxyV2Header(src, dst net.Addr) []byte {
s, sok := src.(*net.TCPAddr)
d, dok := dst.(*net.TCPAddr)
h := append([]byte{}, v2sig...)
if !sok || !dok {
return append(h, 0x20, 0x00, 0, 0)
}
if s4, d4 := s.IP.To4(), d.IP.To4(); s4 != nil && d4 != nil {
h = append(h, 0x21, 0x11, 0, 12)
h = append(h, s4...)
h = append(h, d4...)
} else {
h = append(h, 0x21, 0x21, 0, 36)
h = append(h, s.IP.To16()...)
h = append(h, d.IP.To16()...)
}
h = binary.BigEndian.AppendUint16(h, uint16(s.Port))
return binary.BigEndian.AppendUint16(h, uint16(d.Port))
}
+75
View File
@@ -0,0 +1,75 @@
package proxy
import (
"sort"
"sync"
"sync/atomic"
"time"
)
type serverStats struct {
active atomic.Int64
total atomic.Int64
failures atomic.Int64
bytesIn atomic.Int64 // client -> backend
bytesOut atomic.Int64 // backend -> client
}
type Stats struct {
Started time.Time
active atomic.Int64
total atomic.Int64
rejected atomic.Int64
unknown atomic.Int64
mu sync.Mutex
servers map[string]*serverStats
}
func newStats() *Stats {
return &Stats{Started: time.Now(), servers: map[string]*serverStats{}}
}
func (s *Stats) server(name string) *serverStats {
s.mu.Lock()
defer s.mu.Unlock()
ss := s.servers[name]
if ss == nil {
ss = &serverStats{}
s.servers[name] = ss
}
return ss
}
type ServerSnapshot struct {
Name string `json:"name"`
Active int64 `json:"active"`
Total int64 `json:"total"`
Failures int64 `json:"failures"`
BytesIn int64 `json:"bytes_in"`
BytesOut int64 `json:"bytes_out"`
}
type Snapshot struct {
UptimeSecs int64 `json:"uptime_secs"`
Active int64 `json:"active"`
Total int64 `json:"total"`
Rejected int64 `json:"rejected"`
Unknown int64 `json:"unknown_host"`
Servers []ServerSnapshot `json:"servers"`
}
func (s *Stats) Snapshot() Snapshot {
out := Snapshot{
UptimeSecs: int64(time.Since(s.Started).Seconds()),
Active: s.active.Load(), Total: s.total.Load(),
Rejected: s.rejected.Load(), Unknown: s.unknown.Load(),
}
s.mu.Lock()
for n, ss := range s.servers {
out.Servers = append(out.Servers, ServerSnapshot{n, ss.active.Load(), ss.total.Load(),
ss.failures.Load(), ss.bytesIn.Load(), ss.bytesOut.Load()})
}
s.mu.Unlock()
sort.Slice(out.Servers, func(i, j int) bool { return out.Servers[i].Name < out.Servers[j].Name })
return out
}
+60
View File
@@ -0,0 +1,60 @@
// Package router maps the hostname a client typed to a configured server.
package router
import (
"sort"
"strings"
"git.veillax.com/Veillax/spinner/internal/config"
)
type Router struct {
exact map[string]*config.Server
wild []wildcard
}
type wildcard struct {
suffix string // ".example.com"
srv *config.Server
}
// Normalize lowercases a handshake host and strips the trailing dot and any
// Forge "\0FML\0" suffix.
func Normalize(host string) string {
if i := strings.IndexByte(host, 0); i >= 0 {
host = host[:i]
}
return strings.ToLower(strings.TrimRight(host, "."))
}
// New builds a router. Exact domains win; wildcards (*.example.com) match any
// depth of subdomain but not the apex, and longer suffixes win over shorter.
func New(cfg *config.Config) *Router {
r := &Router{exact: map[string]*config.Server{}}
for i := range cfg.Servers {
s := &cfg.Servers[i]
for _, d := range s.Domains {
d = strings.ToLower(strings.TrimRight(d, "."))
if rest, ok := strings.CutPrefix(d, "*."); ok {
r.wild = append(r.wild, wildcard{"." + rest, s})
} else if _, dup := r.exact[d]; !dup {
r.exact[d] = s
}
}
}
sort.SliceStable(r.wild, func(i, j int) bool { return len(r.wild[i].suffix) > len(r.wild[j].suffix) })
return r
}
func (r *Router) Match(host string) *config.Server {
host = Normalize(host)
if s, ok := r.exact[host]; ok {
return s
}
for _, w := range r.wild {
if strings.HasSuffix(host, w.suffix) && len(host) > len(w.suffix) {
return w.srv
}
}
return nil
}
+55
View File
@@ -0,0 +1,55 @@
package router
import (
"testing"
"git.veillax.com/Veillax/spinner/internal/config"
)
func TestMatch(t *testing.T) {
cfg, err := config.Parse([]byte(`{"servers":[
{"name":"a","domains":["a.example.com","*.deep.example.com"],"addresses":["127.0.0.1:1"]},
{"name":"b","domains":["*.example.com"],"addresses":["127.0.0.1:2"]},
{"name":"c","domains":["*.x.deep.example.com"],"addresses":["127.0.0.1:3"]}
]}`))
if err != nil {
t.Fatal(err)
}
r := New(cfg)
cases := map[string]string{
"a.example.com": "a",
"A.Example.COM.": "a",
"a.example.com\x00FML\x00": "a",
"q.deep.example.com": "a",
"other.example.com": "b",
"s.x.deep.example.com": "c",
"example.com": "",
"evil.com": "",
"notexample.com": "",
"": "",
}
for host, want := range cases {
got := ""
if s := r.Match(host); s != nil {
got = s.Name
}
if got != want {
t.Errorf("Match(%q) = %q, want %q", host, got, want)
}
}
}
func TestConfigValidation(t *testing.T) {
bad := []string{
`{"servers":[{"name":"","domains":["a"],"addresses":["h:1"]}]}`,
`{"servers":[{"name":"a","domains":[],"addresses":["h:1"]}]}`,
`{"servers":[{"name":"a","domains":["a"],"addresses":["nope"]}]}`,
`{"servers":[{"name":"a","domains":["a"],"addresses":["h:1"]},{"name":"a","domains":["b"],"addresses":["h:1"]}]}`,
`{"unknown_field":1}`,
}
for _, b := range bad {
if _, err := config.Parse([]byte(b)); err == nil {
t.Errorf("expected error for %s", b)
}
}
}
+57
View File
@@ -0,0 +1,57 @@
// Style gate for the dashboard, following the Lightwell design language (see docs/design.md).
// Fails on em or en dashes, emoji, exclamation marks in interface copy, and raw colors outside the
// token block of web/index.html. Run with: node scripts/lint-design.mjs
import { readdirSync, readFileSync, statSync } from "node:fs";
import { extname, join, relative, sep } from "node:path";
const root = new URL("..", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1");
const targets = ["web", "docs", "cmd", "internal", "scripts", "README.md", ".gitea", "."];
const skipDirs = new Set(["node_modules", ".git"]);
const exts = new Set([".html", ".md", ".go", ".sh", ".mjs", ".json", ".yml", ".example"]);
const dash = /[–—−]/;
const emoji = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{1F900}-\u{1F9FF}\u{FE0F}\u{2B50}\u{2705}\u{274C}]/u;
const rawColor = /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(/;
const seen = new Set();
function* walk(p, top) {
const st = statSync(p, { throwIfNoEntry: false });
if (!st || seen.has(p)) return;
if (st.isFile()) { seen.add(p); return yield p; }
for (const name of readdirSync(p)) {
if (skipDirs.has(name) || (top && name.startsWith(".") && name !== ".gitea")) continue;
const full = join(p, name);
// the "." target only contributes the files at the top level
if (p === root && statSync(full).isDirectory()) continue;
yield* walk(full, false);
}
}
const problems = [];
for (const t of targets) {
for (const file of walk(join(root, t), true)) {
if (!exts.has(extname(file)) && !file.endsWith(".env.example")) continue;
const rel = relative(root, file).split(sep).join("/");
if (rel.endsWith("go.sum") || rel === "spinner.example.json" || rel.endsWith("_test.go") || rel === "scripts/lint-design.mjs") continue;
const isPage = rel.startsWith("web/") && rel.endsWith(".html");
let inTokens = false;
readFileSync(file, "utf8").split("\n").forEach((line, i) => {
const at = (msg) => problems.push(`${rel}:${i + 1}: ${msg}`);
if (line.includes("tokens:begin")) inTokens = true;
if (dash.test(line)) at("em dash, en dash or minus sign. Use a comma, colon, period or the word to.");
if (emoji.test(line)) at("emoji or pictographic symbol. Use an approved outline icon.");
if (isPage && !inTokens && rawColor.test(line.replace(/\/\*.*?\*\//g, "")) && !/placeholder|href="#|&#/.test(line)) {
at("raw color outside the token block. Use a token.");
}
if (isPage && /(?:toast|textContent\s*=|class:'(?:empty|notice)')[^;\n]*'[^']*[A-Za-z]![^']*'/.test(line)) at("exclamation mark in interface copy.");
if (line.includes("tokens:end")) inTokens = false;
});
}
}
if (problems.length) {
console.error(problems.join("\n"));
console.error(`\n${problems.length} style problem(s).`);
process.exit(1);
}
console.log("Style checks passed.");
+24
View File
@@ -0,0 +1,24 @@
{
"bind": ":25565",
"admin_bind": "127.0.0.1:8080",
"handshake_timeout_secs": 5,
"connect_timeout_secs": 3,
"probe_interval_secs": 10,
"max_conns_per_ip": 20,
"unknown_motd": "§cUnknown server\n§7Check the address you connected to",
"servers": [
{
"name": "survival",
"domains": ["survival.example.com", "*.survival.example.com"],
"addresses": ["127.0.0.1:25566"],
"send_proxy_protocol": false,
"offline_motd": "§eSurvival is sleeping\n§7Try again in a moment",
"max_connections": 100
},
{
"name": "creative",
"domains": ["creative.example.com", "localhost"],
"addresses": ["127.0.0.1:25567", "10.0.0.5:25565"]
}
]
}
+543
View File
@@ -0,0 +1,543 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 32 32%27%3E%3Crect width=%2732%27 height=%2732%27 rx=%276%27 fill=%27%231f5c5c%27/%3E%3Ccircle cx=%2716%27 cy=%2716%27 r=%274%27 fill=%27none%27 stroke=%27white%27 stroke-width=%272.2%27/%3E%3Cpath d=%27M16 5a11 11 0 0 1 11 11M16 27A11 11 0 0 1 5 16%27 fill=%27none%27 stroke=%27white%27 stroke-width=%272.2%27 stroke-linecap=%27round%27/%3E%3C/svg%3E">
<title>Spinner</title>
<style>
/* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
:root {
--accent:#1f5c5c; --accent-contrast:#ffffff;
--bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
--text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
--accent-text:var(--accent);
--accent-soft:color-mix(in srgb, var(--accent) 9%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 88%, #000000);
--focus:color-mix(in srgb, var(--accent) 70%, #ffffff);
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --neutral-bg:#eceae6;
--overlay:rgb(20 19 17 / 0.55); --shadow:0 8px 24px rgb(20 19 17 / 0.14);
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
--radius:6px; --radius-lg:10px; --border-w:1px;
--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--text-xs:12px; --text-sm:13px; --text-base:15px; --text-lg:18px; --text-xl:22px; --text-2xl:28px; --leading:1.5;
--sidebar-w:232px; --content-max:1120px; --touch:44px; --control-h:38px;
--z-sticky:20; --z-overlay:100; --z-toast:200; --ease:140ms ease;
color-scheme:light;
}
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
} }
:root[data-theme="dark"] {
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
}
/* tokens:end */
*, *::before, *::after { box-sizing:border-box; }
html, body { height:100%; }
html { -webkit-text-size-adjust:100%; }
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/var(--leading) var(--font); -webkit-font-smoothing:antialiased; }
h1, h2, h3, h4 { margin:0; line-height:1.25; font-weight:600; letter-spacing:-.01em; }
h1 { font-size:var(--text-2xl); } h2 { font-size:var(--text-xl); } h3 { font-size:var(--text-lg); }
p { margin:0 0 var(--s3); }
a { color:var(--accent-text); text-underline-offset:2px; }
a:hover { text-decoration-thickness:2px; }
:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); }
.sr-only { position:absolute; width:1px; height:1px; margin:-1px; padding:0; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0; }
.skip { position:absolute; left:var(--s3); top:-100px; z-index:var(--z-toast); background:var(--surface); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); }
.skip:focus { top:var(--s3); }
.dim { color:var(--text-muted); } .err { color:var(--danger); } .small { font-size:var(--text-sm); }
.num { font-variant-numeric:tabular-nums; }
/* long machine messages (certificate errors) wrap and stop at three lines; the full text is in the tooltip */
.clip { display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; overflow-wrap:anywhere; max-width:340px; }
code, .mono { font-family:var(--font-mono); font-size:var(--text-sm); }
.icon { width:18px; height:18px; flex:none; stroke-width:1.75; }
/* shell */
.shell { display:grid; grid-template-columns:var(--sidebar-w) minmax(0,1fr); min-height:100vh; }
.sidebar { position:sticky; top:0; height:100vh; display:flex; flex-direction:column; gap:var(--s3); padding:var(--s5) var(--s3); background:var(--surface); border-right:var(--border-w) solid var(--border); overflow-y:auto; }
.sidebar-head { display:flex; align-items:center; justify-content:space-between; gap:var(--s2); }
.sidebar button.sidebar-close { display:none; }
.brand { display:flex; align-items:center; gap:var(--s2); font-size:var(--text-lg); font-weight:700; letter-spacing:-.02em; padding:0 var(--s3); color:var(--text); text-decoration:none; }
.brand .icon { color:var(--accent-text); }
.orgsel select { width:100%; }
.navsec { font-size:var(--text-xs); font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-muted); padding:var(--s4) var(--s3) var(--s1); }
.nav { display:flex; flex-direction:column; gap:2px; }
.navitem { display:flex; align-items:center; gap:var(--s3); width:100%; min-height:36px; padding:var(--s2) var(--s3); border-radius:var(--radius); color:var(--text-muted); text-decoration:none; font:inherit; font-weight:500; background:none; border:0; cursor:pointer; text-align:left; }
.navitem:hover { background:var(--surface-2); color:var(--text); }
.navitem.on { background:var(--accent-soft); color:var(--accent-text); }
.grow { flex:1 1 auto; min-width:0; }
.userbox { border-top:var(--border-w) solid var(--border); padding-top:var(--s3); }
.userbox .who { padding:var(--s1) var(--s3) var(--s2); font-size:var(--text-sm); color:var(--text-muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.content { min-width:0; }
main { max-width:var(--content-max); margin:0 auto; padding:var(--s6) var(--s5) var(--s8); }
.topbar, .backdrop { display:none; }
.page-h { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--s4); margin-bottom:var(--s5); flex-wrap:wrap; }
.page-h p { margin:var(--s1) 0 0; color:var(--text-muted); max-width:70ch; }
.page-h .btn { white-space:nowrap; }
/* cards, layout */
.card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); margin-bottom:var(--s4); overflow-x:auto; }
.card.flush { padding:0; overflow:hidden; }
.card > h2 { font-size:var(--text-lg); margin:0 0 var(--s3); }
.row-h { display:flex; align-items:center; justify-content:flex-end; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
.row-h h2 { display:none; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:var(--s4); align-items:start; }
.stack { display:flex; flex-direction:column; gap:var(--s4); }
.stat { padding:var(--s4); margin-bottom:0; }
.stat-n { font-size:var(--text-xl); font-weight:600; letter-spacing:-.01em; }
.grid + .card, .grid + .empty { margin-top:var(--s4); }
[hidden] { display:none !important; }
/* a button or checkbox in a row of fields lines up with the inputs, below their labels */
.grid > button { justify-self:start; margin-top:calc(var(--text-sm) * var(--leading) + var(--s1)); }
.grid > label.chk { margin-top:calc(var(--text-sm) * var(--leading) + var(--s1) + var(--s2)); }
.sect-h { padding:var(--s3) var(--s4); font-weight:600; border-bottom:var(--border-w) solid var(--border); }
.scroll { overflow-x:auto; }
.actions { display:flex; gap:var(--s2); flex-wrap:wrap; }
.form-actions { justify-content:flex-end; margin-top:var(--s2); }
.toolbar { display:flex; align-items:center; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
.toolbar input[type=search] { max-width:320px; }
.center { max-width:440px; margin:var(--s8) auto; padding:0 var(--s4); }
.center .brand { justify-content:center; font-size:var(--text-xl); padding-bottom:var(--s4); }
/* choices (add resource) */
.choices { display:grid; grid-template-columns:repeat(auto-fill,minmax(210px,1fr)); gap:var(--s3); }
.choice { text-align:left; background:var(--surface); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius-lg); padding:var(--s3) var(--s4); color:var(--text); cursor:pointer; font:inherit; display:flex; flex-direction:column; gap:var(--s1); }
.choice:hover { background:var(--surface-2); }
.choice.on { border-color:var(--accent); background:var(--accent-soft); }
.choice-t { font-weight:600; }
/* update progress and log */
.progress { height:6px; background:var(--surface-2); border-radius:999px; overflow:hidden; }
.progress > div { height:100%; background:var(--accent); transition:width var(--ease); }
.progress > div.bad { background:var(--danger); }
.logbox { margin:0; background:var(--bg); border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s3); font:var(--text-xs)/1.6 var(--font-mono); max-height:260px; overflow:auto; white-space:pre-wrap; word-break:break-word; }
/* forms */
label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; color:var(--text); }
label.chk { flex-direction:row; align-items:flex-start; gap:var(--s2); font-size:var(--text-base); font-weight:400; }
input, select, textarea { background:var(--surface); color:var(--text); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; font-weight:400; min-width:0; width:100%; }
textarea { min-height:96px; resize:vertical; }
input[type=checkbox] { width:16px; height:16px; min-height:0; padding:0; margin-top:3px; accent-color:var(--accent); }
input:focus, select:focus, textarea:focus { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); }
input:disabled, select:disabled { opacity:.6; }
input[aria-invalid="true"] { border-color:var(--danger); }
.sub { color:var(--text-muted); font-size:var(--text-sm); font-weight:400; }
/* buttons: one height and shape. btn is the primary action, ghost the default, danger and quiet the others */
button.btn, button.ghost, .btn-link { display:inline-flex; align-items:center; justify-content:center; gap:var(--s2); min-height:var(--control-h); padding:0 var(--s4); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; white-space:nowrap; text-decoration:none; transition:background var(--ease), border-color var(--ease); }
button.btn { background:var(--accent); color:var(--accent-contrast); border:var(--border-w) solid var(--accent); }
button.btn:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
button.ghost { background:var(--surface); border:var(--border-w) solid var(--border-strong); color:var(--text); }
button.ghost:hover { background:var(--surface-2); }
button.quiet { background:transparent; border-color:transparent; }
button.danger { color:var(--danger); border-color:var(--danger); }
button.danger:hover { background:var(--danger-bg); }
button.btn-icon { width:var(--control-h); padding:0; }
td button.ghost, .actions button.ghost, .tgt button.ghost { min-height:30px; padding:0 var(--s3); font-size:var(--text-sm); }
button:disabled { opacity:.55; cursor:not-allowed; }
/* tables */
table { width:100%; border-collapse:collapse; }
th { text-align:left; font-size:var(--text-sm); color:var(--text-muted); font-weight:600; padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); white-space:nowrap; background:var(--surface); }
td { padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); vertical-align:middle; }
tbody tr:hover td { background:var(--surface-2); }
tr:last-child td { border-bottom:0; }
/* badges and tags: status always carries a text label, never color alone */
.badge { display:inline-flex; align-items:center; gap:var(--s1); padding:2px var(--s2); border-radius:999px; font-size:var(--text-xs); font-weight:600; background:var(--neutral-bg); color:var(--text-muted); white-space:nowrap; }
.badge-success { background:var(--success-bg); color:var(--success); }
.badge-warning { background:var(--warning-bg); color:var(--warning); }
.badge-danger { background:var(--danger-bg); color:var(--danger); }
.badge-accent { background:var(--accent-soft); color:var(--accent-text); }
.tag { display:inline-block; padding:1px var(--s2); border:var(--border-w) solid var(--border); border-radius:var(--radius); font-size:var(--text-xs); color:var(--text-muted); }
pre { background:var(--surface-2); border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s3); overflow:auto; margin:var(--s2) 0 0; user-select:all; white-space:pre-wrap; word-break:break-all; }
/* empty state, notice, loading */
.empty { text-align:center; padding:var(--s7) var(--s5); color:var(--text-muted); border:var(--border-w) dashed var(--border-strong); border-radius:var(--radius-lg); margin:var(--s3); }
.empty h3 { color:var(--text); margin-bottom:var(--s1); }
.empty p { margin:0 auto var(--s3); max-width:56ch; }
.empty .icon { width:28px; height:28px; margin:0 auto var(--s3); color:var(--text-faint); display:block; }
.card > .empty { margin:0; }
.notice { padding:var(--s3) var(--s4); border-radius:var(--radius); border:var(--border-w) solid var(--border); background:var(--surface-2); font-size:var(--text-sm); margin-bottom:var(--s4); }
.notice-danger { background:var(--danger-bg); border-color:var(--danger); color:var(--danger); }
.notice-success { background:var(--success-bg); border-color:var(--success); color:var(--success); }
.skeleton { background:linear-gradient(90deg, var(--surface-2), var(--border), var(--surface-2)); background-size:200% 100%; animation:shimmer 1.3s infinite; border-radius:var(--radius); }
@keyframes shimmer { to { background-position:-200% 0; } }
/* dialogs, tabs, toasts */
.shade { position:fixed; inset:0; background:var(--overlay); display:grid; place-items:start center; padding:var(--s7) var(--s4); overflow:auto; z-index:var(--z-overlay); }
.modal { background:var(--surface); border-radius:var(--radius-lg); box-shadow:var(--shadow); padding:var(--s5); width:min(480px,100%); }
.modal.wide { width:min(780px,100%); }
.modal h2 { margin:0 0 var(--s3); font-size:var(--text-lg); }
fieldset { border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s4); margin:0; min-width:0; }
legend { font-size:var(--text-sm); font-weight:600; color:var(--text-muted); padding:0 var(--s2); }
.tgt { display:grid; grid-template-columns:1.2fr 1.4fr .7fr auto auto; gap:var(--s2); align-items:end; }
.pillrow { display:flex; flex-wrap:wrap; gap:var(--s2) var(--s4); }
.tabs2 { display:inline-flex; margin-bottom:var(--s4); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); overflow:hidden; max-width:100%; flex-wrap:wrap; background:var(--surface); }
.tabs2 button { background:var(--surface); border:0; color:var(--text-muted); padding:0 var(--s3); min-height:var(--control-h); cursor:pointer; font:inherit; font-weight:500; }
.tabs2 button + button { border-left:var(--border-w) solid var(--border-strong); }
.tabs2 button:hover { background:var(--surface-2); }
.tabs2 button.on { background:var(--accent-soft); color:var(--accent-text); }
.toasts { position:fixed; right:var(--s4); bottom:var(--s4); z-index:var(--z-toast); display:flex; flex-direction:column; gap:var(--s2); max-width:min(360px, calc(100vw - 32px)); }
.toast { background:var(--text); color:var(--bg); padding:var(--s3) var(--s4); border-radius:var(--radius); box-shadow:var(--shadow); font-size:var(--text-sm); }
.toast-error { background:var(--danger); color:var(--accent-contrast); }
@media (prefers-reduced-motion: reduce) { .skeleton { animation:none; } * { transition:none !important; } html { scroll-behavior:auto; } }
@media (pointer: coarse) {
button.btn, button.ghost, .navitem, input, select { min-height:var(--touch); }
td button.ghost, .actions button.ghost, .tgt button.ghost { min-height:36px; }
input, select, textarea { font-size:16px; }
}
@media (max-width:860px) {
.shell { grid-template-columns:minmax(0,1fr); grid-template-rows:auto minmax(0,1fr); }
.sidebar { display:none; }
.sidebar.open { display:flex; position:fixed; inset:0 auto 0 0; width:min(280px,85vw); height:100dvh; z-index:var(--z-overlay); box-shadow:var(--shadow); }
.sidebar button.sidebar-close { display:inline-flex; }
.topbar { display:flex; align-items:center; justify-content:space-between; min-height:52px; padding:var(--s2) var(--s4); background:var(--surface); border-bottom:var(--border-w) solid var(--border); position:sticky; top:0; z-index:var(--z-sticky); }
.backdrop.open { display:block; position:fixed; inset:0; background:var(--overlay); z-index:calc(var(--z-overlay) - 1); }
main { padding:var(--s5) var(--s4) var(--s7); }
.tgt { grid-template-columns:1fr 1fr; }
}
@media (max-width:640px) {
.modal { padding:var(--s4); }
.shade { padding:var(--s4) var(--s3); }
.toasts { left:var(--s3); right:var(--s3); bottom:var(--s3); max-width:none; }
.page-h > .btn { width:100%; }
}
</style>
<body>
<div id="app"></div>
<script>
// ---------- tiny toolkit ----------
function h(tag, props, ...kids) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(props || {})) {
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
else if (k === 'class') el.className = v;
else if (v === true) el.setAttribute(k, '');
else if (v !== false && v != null) el.setAttribute(k, v);
}
for (const k of kids.flat(Infinity)) if (k != null && k !== false) el.append(k.nodeType ? k : document.createTextNode(String(k)));
return el;
}
const $app = document.getElementById('app');
// theme: follows the system unless the person picked one
try { const t = localStorage.getItem('theme'); if (t) document.documentElement.dataset.theme = t; } catch (e) {}
function toggleTheme() {
const dark = document.documentElement.dataset.theme ? document.documentElement.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
const next = dark ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
try { localStorage.setItem('theme', next); } catch (e) {}
}
// short confirmations at the bottom right; errors stay longer and are announced as alerts
let toastBox;
function toast(msg, bad) {
if (!toastBox) { toastBox = h('div', {class:'toasts', 'aria-live':'polite'}); document.body.append(toastBox); }
const t = h('div', {class:'toast' + (bad ? ' toast-error' : ''), role:bad ? 'alert' : 'status'}, msg);
toastBox.append(t);
setTimeout(() => t.remove(), bad ? 7000 : 3000);
}
const SIGNED_OUT = 'Your token was not accepted. Sign in again to continue.';
const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
const S = { token: '', authed: false, tab: 'servers', cfg: null, headerAction: null, drawn: null };
try { S.token = sessionStorage.getItem('token') || ''; } catch (e) {}
async function api(method, url, body) {
const r = await fetch(url, { method, headers: { Authorization: 'Bearer ' + S.token, 'Content-Type': 'application/json' }, body: body !== undefined ? JSON.stringify(body) : undefined });
const text = await r.text();
if (r.status === 401) {
if (S.authed) { S.authed = false; S.token = ''; try { sessionStorage.removeItem('token'); } catch (e) {} boot(); }
throw new Error(SIGNED_OUT);
}
if (!r.ok) throw new Error(cap(text.trim() || r.statusText));
return text ? JSON.parse(text) : {};
}
// run an action; show errors as a toast instead of throwing into the void
const act = fn => async (...a) => { try { await fn(...a); } catch (e) { if (e.message !== SIGNED_OUT) toast(e.message, true); } };
const bytes = n => { n = n || 0; const u = ['B','KB','MB','GB','TB']; let i = 0; while (n >= 1024 && i < 4) { n /= 1024; i++; } return (i ? n.toFixed(1) : n) + ' ' + u[i]; };
const fmtTime = d => new Date(d).toLocaleString(undefined, {month:'short', day:'numeric', year:'numeric', hour:'numeric', minute:'2-digit'});
const uptime = s => s < 90 ? 'Under a minute' : s < 5400 ? Math.round(s / 60) + ' min' : s < 129600 ? Math.round(s / 3600) + ' h' : Math.round(s / 86400) + ' d';
const lines = t => t.split(/[\n,]/).map(x => x.trim()).filter(Boolean);
const field = (label, el, hint) => h('label', {}, label, el, hint ? h('span', {class:'sub'}, hint) : null);
const chk = (label, checked) => { const i = h('input', {type:'checkbox', checked: !!checked}); return Object.assign(h('label', {class:'chk'}, i, label), {box:i}); };
const withValue = (el, v) => { el.value = v == null ? '' : v; return el; };
// ---------- components ----------
const card = (title, ...kids) => h('div', {class:'card'}, title ? h('h2', {}, title) : null, ...kids);
const stat = (label, value) => h('div', {class:'card stat'}, h('div', {class:'small dim'}, label), h('div', {class:'stat-n num'}, value));
// A dialog: focus moves in, Tab stays inside, Escape closes, and focus goes back to what opened it.
let dialogSeq = 0;
function modal(title, body, opts = {}) {
const opener = document.activeElement, id = 'dialog-title-' + (++dialogSeq);
const shade = h('div', {class:'shade'});
const box = h('div', {class:'modal' + (opts.wide || opts.sticky ? ' wide' : ''), role:'dialog', 'aria-modal':'true', 'aria-labelledby':id, tabindex:'-1'}, h('h2', {id}, title), body);
let closed = false;
const close = () => {
if (closed) return; closed = true;
document.removeEventListener('keydown', onKey, true); shade.remove();
if (opener && opener.isConnected && opener.focus) opener.focus();
if (opts.onClose) opts.onClose();
};
const focusables = () => [...box.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')].filter(e => e.offsetParent !== null);
function onKey(e) {
const top = [...document.querySelectorAll('.shade')].pop();
if (top !== shade) return;
if (e.key === 'Escape') { e.stopPropagation(); close(); }
else if (e.key === 'Tab') {
const f = focusables(); if (!f.length) { e.preventDefault(); return; }
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && (document.activeElement === first || document.activeElement === box)) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}
shade.addEventListener('mousedown', e => { if (e.target === shade && !opts.sticky) close(); });
shade.append(box);
document.body.append(shade);
document.addEventListener('keydown', onKey, true);
(box.querySelector('input,select,textarea') || focusables()[0] || box).focus();
return close;
}
function dialogForm(title, intro, fields, label, submit) {
const err = h('div', {class:'notice notice-danger', role:'alert', hidden:true});
const close = modal(title, h('div', {class:'stack'}, intro ? h('p', {class:'dim'}, intro) : null, ...fields, err,
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'),
h('button', {class:'btn', onclick: async () => {
err.hidden = true;
try { await submit(); close(); } catch (e) { if (e.message === SIGNED_OUT) return close(); err.textContent = e.message; err.hidden = false; }
}}, label))), {sticky:true});
}
// A confirmation that names what it affects and says what is lost. Resolves true only when confirmed.
function confirmDialog({title, body, confirm = 'Confirm', cancel = 'Cancel', danger = true}) {
return new Promise(resolve => {
let close;
const done = v => { resolve(v); close(); };
close = modal(title, h('div', {class:'stack'}, h('p', {class:'dim', style:'margin:0'}, body),
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => done(false)}, cancel),
h('button', {class:danger ? 'ghost danger' : 'btn', onclick:() => done(true)}, confirm))), {onClose:() => resolve(false)});
});
}
const ask = (opts, run) => act(async () => { if (await confirmDialog(opts)) await run(); });
// status pill: color always travels with a text label
const badge = (text, tone) => h('span', {class:'badge' + (tone ? ' badge-' + tone : '')}, text);
const emptyState = (name, title, text, action) => h('div', {class:'empty'}, icon(name, 22), h('h3', {}, title), h('p', {}, text), action || null);
// placeholder rows while a page loads, so the layout does not jump
const skeleton = () => h('div', {class:'card', role:'status', 'aria-busy':'true'}, h('span', {class:'sr-only'}, 'Loading'),
...[60, 100, 90, 100, 75].map(w => h('div', {class:'skeleton', style:`height:16px;width:${w}%;margin-bottom:14px`})));
const modalOpen = () => !!document.querySelector('.shade');
// ---------- icons (lucide-style, static strings) ----------
const ICONS = {
spinner:'<circle cx="12" cy="12" r="3"/><path d="M12 3a9 9 0 0 1 9 9M12 21a9 9 0 0 1-9-9"/>',
servers:'<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><path d="M6 6h.01M6 18h.01"/>',
settings:'<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>',
lock:'<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
out:'<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>',
menu:'<path d="M4 6h16M4 12h16M4 18h16"/>',
close:'<path d="M18 6 6 18M6 6l12 12"/>',
add:'<path d="M5 12h14M12 5v14"/>',
check:'<path d="M20 6 9 17l-5-5"/>',
light:'<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>',
dark:'<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
alert:'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/>',
};
function icon(name, size = 18) {
const s = document.createElement('span');
s.style.display = 'inline-flex'; s.setAttribute('aria-hidden', 'true');
s.innerHTML = `<svg class="icon" xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" style="width:${size}px;height:${size}px">${ICONS[name] || ''}</svg>`;
return s;
}
const brand = () => h('div', {class:'brand'}, icon('spinner', 22), 'Spinner');
// ---------- sign in ----------
async function boot() {
if (S.token) {
try { await api('GET', '/api/overview'); S.authed = true; } catch (e) { S.authed = false; }
}
if (!S.authed) return $app.replaceChildren(loginView());
S.tab = (location.hash.slice(1) in PAGES) ? location.hash.slice(1) : 'servers';
await render();
}
function loginView(message) {
const token = h('input', {type:'password', autocomplete:'current-password', required:true, 'aria-describedby':'token-hint'});
const err = h('div', {class:'notice notice-danger', role:'alert', hidden:!message}, message || '');
const form = h('form', {class:'stack', onsubmit: async e => {
e.preventDefault(); err.hidden = true; S.token = token.value;
try {
await api('GET', '/api/overview'); S.authed = true;
try { sessionStorage.setItem('token', S.token); } catch (x) {}
boot();
} catch (x) { S.token = ''; err.textContent = x.message === SIGNED_OUT ? 'That token was not accepted. Check SPINNER_ADMIN_TOKEN in the .env file, then try again.' : x.message; err.hidden = false; token.focus(); }
}}, h('div', {}, h('h1', {style:'font-size:var(--text-xl)'}, 'Sign in'), h('p', {class:'dim', style:'margin-top:var(--s1)'}, 'Use the admin token Spinner was started with.')),
err, field('Admin token', token, 'Set as SPINNER_ADMIN_TOKEN in the .env file.'),
h('div', {class:'actions form-actions'}, h('button', {class:'btn', type:'submit'}, 'Sign in')));
return h('div', {class:'center'}, brand(), card(null, form));
}
// ---------- shell ----------
const PAGES = {
servers: ['Servers', 'Players reach a server by the address they type. Spinner passes the connection to that server\'s backend.'],
settings: ['Settings', 'Changes apply as soon as you save. New listen addresses need a restart.'],
};
async function render() {
const nav = (tab, label, ic) => h('a', {class:'navitem' + (S.tab === tab ? ' on' : ''), href:'#' + tab, 'aria-current':S.tab === tab ? 'page' : null,
onclick: e => { e.preventDefault(); selectTab(tab); setDrawer(false); } }, icon(ic), label);
const side = [h('div', {class:'sidebar-head'}, brand(), h('button', {class:'ghost quiet btn-icon sidebar-close', 'aria-label':'Close menu', onclick:() => setDrawer(false)}, icon('close'))),
h('nav', {class:'nav', 'aria-label':'Main'}, nav('servers', 'Servers', 'servers'), nav('settings', 'Settings', 'settings')),
h('div', {class:'grow'}),
h('div', {class:'userbox'},
h('button', {class:'navitem', onclick:toggleTheme}, icon('dark'), 'Switch theme'),
h('button', {class:'navitem', onclick: () => { S.authed = false; S.token = ''; try { sessionStorage.removeItem('token'); } catch (e) {} boot(); }}, icon('out'), 'Sign out'))];
S.drawn = null;
$app.replaceChildren(h('a', {class:'skip', href:'#view'}, 'Skip to content'), h('div', {class:'shell'},
h('div', {class:'topbar'}, brand(), h('button', {class:'ghost quiet btn-icon', id:'menu-btn', 'aria-label':'Open menu', 'aria-controls':'sidebar', 'aria-expanded':'false', onclick:() => setDrawer(true)}, icon('menu'))),
h('div', {class:'backdrop', id:'backdrop', onclick:() => setDrawer(false)}),
h('aside', {class:'sidebar', id:'sidebar', 'aria-label':'Sidebar'}, side),
h('div', {class:'content'}, h('main', {id:'view', tabindex:'-1'}))));
await refresh();
}
function selectTab(tab) { S.tab = tab; history.replaceState(null, '', '#' + tab); render(); }
// the sidebar is a slide-in drawer on small screens
function setDrawer(open) {
const sb = document.getElementById('sidebar'), bd = document.getElementById('backdrop'), mb = document.getElementById('menu-btn');
if (!sb) return;
sb.classList.toggle('open', open); bd.classList.toggle('open', open); mb.setAttribute('aria-expanded', open);
if (open) { const f = sb.querySelector('a,button'); if (f) f.focus(); } else if (mb.offsetParent) mb.focus();
}
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !modalOpen()) { const sb = document.getElementById('sidebar'); if (sb && sb.classList.contains('open')) setDrawer(false); } });
function pageHeader(tab) {
const [t, d] = PAGES[tab], a = S.headerAction;
return h('div', {class:'page-h'}, h('div', {}, h('h1', {}, t), d ? h('p', {}, d) : null), a ? h('button', {class:'btn', onclick:a[1]}, icon('add', 16), a[0]) : null);
}
let seq = 0;
async function refresh() {
const my = ++seq;
try {
S.headerAction = null;
const first = document.getElementById('view');
if (first && S.drawn !== S.tab) first.replaceChildren(pageHeader(S.tab), skeleton());
const view = await views[S.tab]();
if (my !== seq) return;
const v = document.getElementById('view');
if (v) { v.replaceChildren(pageHeader(S.tab), view); S.drawn = S.tab; }
} catch (e) { if (e.message !== SIGNED_OUT) toast(e.message, true); }
}
setInterval(() => {
if (!S.authed || document.hidden || modalOpen() || S.tab !== 'servers') return;
refresh();
}, 4000);
// ---------- views ----------
const views = {};
views.servers = async () => {
const [ov, cfg] = await Promise.all([api('GET', '/api/overview'), api('GET', '/api/config')]);
S.cfg = cfg;
S.headerAction = ['Add server', () => serverDialog()];
const t = ov.totals;
const totals = h('div', {class:'grid'}, stat('Connected now', t.active), stat('Connections since start', t.total), stat('Turned away', t.rejected), stat('Unknown address', t.unknown_host), stat('Running for', uptime(t.uptime_secs)));
if (!ov.servers.length) return h('div', {}, totals, emptyState('servers', 'No servers yet', 'Add a server to start routing players. Each one needs an address players type and a backend to send them to.',
h('button', {class:'btn', onclick:() => serverDialog()}, 'Add server')));
const rows = ov.servers.map(s => {
const hl = s.health, st = s.stats || {};
const status = !hl ? badge('Checking') : hl.up ? badge('Online', 'success') : badge('Offline', 'danger');
const backend = h('td', {}, s.addresses.map(a => h('div', {class:'mono'}, a)),
hl && hl.up ? h('div', {class:'small dim'}, hl.latency_ms + ' ms' + (hl.version ? ', ' + hl.version : '')) : null,
hl && !hl.up ? h('div', {class:'small dim clip', title:hl.error}, hl.error) : null);
return h('tr', {},
h('td', {}, h('div', {}, h('strong', {}, s.name)), h('div', {style:'margin-top:var(--s1)'}, status)),
h('td', {}, s.domains.map(d => h('div', {class:'mono'}, d))),
backend,
h('td', {class:'num'}, hl && hl.up ? hl.online + ' of ' + hl.max : h('span', {class:'dim'}, 'Not available')),
h('td', {class:'num'}, (st.active || 0) + ' now', h('div', {class:'small dim'}, (st.total || 0) + ' total')),
h('td', {class:'num small'}, h('div', {}, 'In ' + bytes(st.bytes_in)), h('div', {class:'dim'}, 'Out ' + bytes(st.bytes_out))),
h('td', {}, h('div', {class:'actions'},
h('button', {class:'ghost', onclick:() => serverDialog(s)}, 'Edit'),
h('button', {class:'ghost danger', onclick: ask({title:'Delete ' + s.name + '?', confirm:'Delete server',
body:'Players who use ' + s.domains[0] + ' will see the unknown server message. The backend is not touched, and you can add the server again later.'},
async () => { await saveCfg(c => { c.servers = c.servers.filter(x => x.name !== s.name); }); toast(s.name + ' deleted.'); refresh(); })}, 'Delete'))));
});
return h('div', {}, totals, h('div', {class:'card flush scroll'}, h('table', {},
h('thead', {}, h('tr', {}, ['Server', 'Addresses players use', 'Backend', 'Players', 'Connections', 'Traffic', ''].map(x => h('th', {}, x)))), h('tbody', {}, rows))));
};
// Reads the current config, lets fn change a copy, and saves it. The server checks it and says what is wrong.
async function saveCfg(fn) {
const c = await api('GET', '/api/config');
fn(c);
S.cfg = await api('PUT', '/api/config', c);
}
function serverDialog(existing) {
const s = existing || {name:'', domains:[], addresses:[]};
const name = withValue(h('input', {autocomplete:'off'}), s.name);
const domains = withValue(h('textarea', {rows:3, placeholder:'survival.example.com'}), s.domains.join('\n'));
const addresses = withValue(h('textarea', {rows:2, placeholder:'127.0.0.1:25566'}), s.addresses.join('\n'));
const offline = withValue(h('input', {autocomplete:'off'}), s.offline_motd);
const max = withValue(h('input', {type:'number', min:'0', inputmode:'numeric'}), s.max_connections || '');
const proxy = chk('Send the player\'s address to the backend with the PROXY protocol', s.send_proxy_protocol);
dialogForm(existing ? 'Edit ' + s.name : 'Add server', null, [
field('Name', name, 'Shown here and in metrics.'),
field('Addresses players use', domains, 'One per line. Start with *. to match every subdomain, for example *.example.com.'),
field('Backend addresses', addresses, 'One per line, as host:port. Spinner tries them in order.'),
field('Message when the backend is offline', offline, 'Shown in the server list and when a player tries to join.'),
field('Most players at once', max, 'Leave empty for no limit.'),
proxy,
], existing ? 'Save server' : 'Add server', async () => {
const next = {name:name.value.trim(), domains:lines(domains.value), addresses:lines(addresses.value)};
if (offline.value.trim()) next.offline_motd = offline.value.trim();
if (+max.value > 0) next.max_connections = +max.value;
if (proxy.box.checked) next.send_proxy_protocol = true;
await saveCfg(c => {
const i = existing ? c.servers.findIndex(x => x.name === existing.name) : -1;
if (i >= 0) c.servers[i] = next; else c.servers.push(next);
});
toast(next.name + ' saved.');
refresh();
});
}
views.settings = async () => {
const c = S.cfg = await api('GET', '/api/config');
const num = (v, min) => withValue(h('input', {type:'number', min:String(min), inputmode:'numeric'}), v);
const bind = withValue(h('input', {autocomplete:'off'}), c.bind), admin = withValue(h('input', {autocomplete:'off'}), c.admin_bind);
const hs = num(c.handshake_timeout_secs, 1), ct = num(c.connect_timeout_secs, 1), pi = num(c.probe_interval_secs, 1), ip = num(c.max_conns_per_ip, 0);
const unknown = withValue(h('textarea', {rows:3}), c.unknown_motd);
const err = h('div', {class:'notice notice-danger', role:'alert', hidden:true});
const save = h('button', {class:'ghost', onclick: async () => {
err.hidden = true;
try {
await saveCfg(n => {
n.bind = bind.value.trim(); n.admin_bind = admin.value.trim(); n.unknown_motd = unknown.value;
n.handshake_timeout_secs = +hs.value; n.connect_timeout_secs = +ct.value; n.probe_interval_secs = +pi.value; n.max_conns_per_ip = +ip.value;
});
toast(c.bind !== S.cfg.bind || c.admin_bind !== S.cfg.admin_bind ? 'Settings saved. Restart Spinner to use the new listen address.' : 'Settings saved.');
} catch (e) { if (e.message !== SIGNED_OUT) { err.textContent = e.message; err.hidden = false; } }
}}, 'Save settings');
return h('div', {}, card('Listening',
h('div', {class:'grid'}, field('Minecraft address', bind, 'Where players connect, for example :25565.'), field('Admin address', admin, 'Where this page is served. Empty turns it off.'))),
card('Timeouts and limits', h('div', {class:'grid'},
field('Wait for a handshake (seconds)', hs), field('Wait for a backend (seconds)', ct), field('Check backends every (seconds)', pi),
field('Most connections per IP address', ip, '0 means no limit.'))),
card('Unknown addresses', field('Message', unknown, 'Shown when a player uses an address that no server claims.')),
err, h('div', {class:'actions form-actions'}, save));
};
boot();
</script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
// Package web embeds the admin UI.
package web
import _ "embed"
//go:embed index.html
var Index []byte