Files
VeillaxandClaude Sonnet 5.5 4afbf2d266
CI / test (push) Successful in 54s
Release / release (push) Successful in 2m31s
Docker / image (push) Successful in 2m52s
Fix the Servers page failing when no servers are configured
An empty server list was sent as null, so the page threw on .length and
showed only the loading placeholder. The API now always returns a list,
the page tolerates null, and tests cover the empty config and the token.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 23:58:26 -06:00

223 lines
5.5 KiB
Go

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