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:
1 parent
e640c5bf02
commit
b53af0e196
29 files changed
+2640
No files matched your search
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user