Files
Spinner/cmd/spinner/main.go
T
VeillaxandClaude Sonnet 5.5 b53af0e196
CI / test (push) Successful in 2m32s
Add Spinner, a Minecraft hostname-routing reverse proxy
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>
2026-10-26 19:40:40 -06:00

115 lines
3.0 KiB
Go

// 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
}