// 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" } if c.Servers == nil { c.Servers = []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() } } }