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:
1 parent
e640c5bf02
commit
b53af0e196
29 files changed
+2640
No files matched your search
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!dist/bin
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 .
|
||||
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
spinner.exe
|
||||
/spinner
|
||||
/dist
|
||||
go.work
|
||||
go.work.sum
|
||||
+20
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/"
|
||||
@@ -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:
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
module git.veillax.com/Veillax/spinner
|
||||
|
||||
go 1.27.3
|
||||
|
||||
require git.veillax.com/Veillax/reef v0.1.7
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
@@ -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
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
// Package web embeds the admin UI.
|
||||
package web
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed index.html
|
||||
var Index []byte
|
||||
Reference in new issue
Block a user