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>
223 lines
5.5 KiB
Go
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()
|
|
}
|
|
}
|
|
}
|