Add Spinner, a Minecraft hostname-routing reverse proxy
CI / test (push) Successful in 2m32s

Routes Java Edition connections by the hostname in the handshake, with
wildcard domains, backend failover, PROXY protocol v2, cached server-list
status from health probes, hot-reloaded config, and an admin page and
metrics endpoint. Includes CI, tagged releases and a container image.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-26 19:40:40 -06:00
1 parent e640c5bf02
commit b53af0e196
29 files changed
+2640

No files matched your search

+219
View File
@@ -0,0 +1,219 @@
// Package config loads, validates, saves and hot-reloads Spinner's JSON config.
package config
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
)
type Config struct {
Bind string `json:"bind"`
AdminBind string `json:"admin_bind"`
HandshakeTimeoutSecs int `json:"handshake_timeout_secs"`
ConnectTimeoutSecs int `json:"connect_timeout_secs"`
ProbeIntervalSecs int `json:"probe_interval_secs"`
MaxConnsPerIP int `json:"max_conns_per_ip"`
UnknownMOTD string `json:"unknown_motd"`
Servers []Server `json:"servers"`
}
type Server struct {
Name string `json:"name"`
Domains []string `json:"domains"`
Addresses []string `json:"addresses"`
SendProxyProtocol bool `json:"send_proxy_protocol,omitempty"`
OfflineMOTD string `json:"offline_motd,omitempty"`
MaxConnections int `json:"max_connections,omitempty"`
}
func (c *Config) HandshakeTimeout() time.Duration {
return time.Duration(c.HandshakeTimeoutSecs) * time.Second
}
func (c *Config) ConnectTimeout() time.Duration {
return time.Duration(c.ConnectTimeoutSecs) * time.Second
}
func (c *Config) ProbeInterval() time.Duration {
return time.Duration(c.ProbeIntervalSecs) * time.Second
}
// Parse decodes, applies defaults to and validates a config document.
func Parse(data []byte) (*Config, error) {
var c Config
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&c); err != nil {
return nil, err
}
def := func(v *int, d int) {
if *v <= 0 {
*v = d
}
}
if c.Bind == "" {
c.Bind = ":25565"
}
def(&c.HandshakeTimeoutSecs, 5)
def(&c.ConnectTimeoutSecs, 3)
def(&c.ProbeIntervalSecs, 10)
if c.UnknownMOTD == "" {
c.UnknownMOTD = "§cUnknown server"
}
return &c, c.validate()
}
func (c *Config) validate() error {
seen := map[string]bool{}
for i := range c.Servers {
s := &c.Servers[i]
if s.Name == "" {
return fmt.Errorf("server %d has no name. Give every server a name", i+1)
}
if seen[s.Name] {
return fmt.Errorf("two servers are named %q. Names must be different", s.Name)
}
seen[s.Name] = true
if len(s.Domains) == 0 {
return fmt.Errorf("server %q has no addresses for players. Add at least one", s.Name)
}
if len(s.Addresses) == 0 {
return fmt.Errorf("server %q has no backend address. Add at least one as host:port", s.Name)
}
for _, a := range s.Addresses {
if _, _, err := net.SplitHostPort(a); err != nil {
return fmt.Errorf("server %q: backend address %q is not host:port (%v)", s.Name, a, err)
}
}
for _, d := range s.Domains {
if strings.TrimSpace(d) == "" || strings.Contains(strings.TrimPrefix(d, "*."), "*") {
return fmt.Errorf("server %q: %q is not a valid address for players. Use a name like play.example.com or *.example.com", s.Name, d)
}
}
}
return nil
}
// Manager owns the current config, persists changes and reloads on file edits.
type Manager struct {
path string
cur atomic.Pointer[Config]
mu sync.Mutex // guards raw and subs
raw []byte
subs []func(*Config)
log *slog.Logger
}
func NewManager(path string, log *slog.Logger) (*Manager, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
c, err := Parse(data)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
m := &Manager{path: path, raw: data, log: log}
m.cur.Store(c)
return m, nil
}
func (m *Manager) Current() *Config { return m.cur.Load() }
// OnChange registers a callback invoked after every successful reload or save.
func (m *Manager) OnChange(f func(*Config)) {
m.mu.Lock()
m.subs = append(m.subs, f)
m.mu.Unlock()
}
func (m *Manager) apply(data []byte, c *Config) {
m.mu.Lock()
m.raw = data
subs := append([]func(*Config){}, m.subs...)
m.mu.Unlock()
m.cur.Store(c)
for _, f := range subs {
f(c)
}
}
// Save validates c, writes it atomically and applies it immediately.
func (m *Manager) Save(c *Config) error {
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
parsed, err := Parse(data)
if err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(m.path), ".spinner-*.json")
if err != nil {
return err
}
_, werr := tmp.Write(append(data, '\n'))
cerr := tmp.Close()
if err := errors.Join(werr, cerr); err != nil {
os.Remove(tmp.Name())
return err
}
if err := os.Rename(tmp.Name(), m.path); err != nil {
os.Remove(tmp.Name())
return err
}
m.mu.Lock()
m.raw = append(data, '\n')
m.mu.Unlock()
m.apply(m.raw, parsed)
return nil
}
func (m *Manager) reloadIfChanged() {
data, err := os.ReadFile(m.path)
if err != nil {
return
}
m.mu.Lock()
same := bytes.Equal(data, m.raw)
m.mu.Unlock()
if same {
return
}
c, err := Parse(data)
if err != nil {
m.log.Error("config reload rejected, keeping previous config", "err", err)
m.mu.Lock()
m.raw = data // don't repeat the error until the file changes again
m.mu.Unlock()
return
}
if old := m.Current(); old.Bind != c.Bind || old.AdminBind != c.AdminBind {
m.log.Warn("bind address changes need a restart")
}
m.apply(data, c)
m.log.Info("config reloaded", "servers", len(c.Servers))
}
// Watch polls the file until ctx ends.
func (m *Manager) Watch(ctx context.Context) {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
m.reloadIfChanged()
}
}
}