CI / test (push) Successful in 2m32s
Routes Java Edition connections by the hostname in the handshake, with wildcard domains, backend failover, PROXY protocol v2, cached server-list status from health probes, hot-reloaded config, and an admin page and metrics endpoint. Includes CI, tagged releases and a container image. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
115 lines
3.0 KiB
Go
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
|
|
}
|