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