Files
VeillaxandClaude Sonnet 5.5 b53af0e196
CI / test (push) Successful in 2m32s
Add Spinner, a Minecraft hostname-routing reverse proxy
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>
2026-10-26 19:40:40 -06:00

155 lines
3.7 KiB
Go

// Package health probes backends with a status ping, caching their server-list
// response and tracking whether each one is up.
package health
import (
"context"
"encoding/json"
"log/slog"
"net"
"sort"
"sync"
"time"
"git.veillax.com/Veillax/spinner/internal/config"
"git.veillax.com/Veillax/spinner/internal/protocol"
)
type State struct {
Name string `json:"name"`
Up bool `json:"up"`
LastCheck time.Time `json:"last_check"`
LatencyMS int64 `json:"latency_ms"`
Error string `json:"error,omitempty"`
Version string `json:"version,omitempty"`
Online int `json:"online"`
Max int `json:"max"`
status string
}
type Tracker struct {
mu sync.RWMutex
st map[string]*State
log *slog.Logger
}
func New(log *slog.Logger) *Tracker {
return &Tracker{st: map[string]*State{}, log: log}
}
// Cached returns the backend's last status JSON if it is up and the probe is
// no older than maxAge.
func (t *Tracker) Cached(name string, maxAge time.Duration) (string, bool) {
t.mu.RLock()
defer t.mu.RUnlock()
s := t.st[name]
if s == nil || !s.Up || s.status == "" || time.Since(s.LastCheck) > maxAge {
return "", false
}
return s.status, true
}
func (t *Tracker) Snapshot() []State {
t.mu.RLock()
defer t.mu.RUnlock()
out := make([]State, 0, len(t.st))
for _, s := range t.st {
out = append(out, *s)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Run probes every server immediately and then on each interval until ctx ends.
func (t *Tracker) Run(ctx context.Context, m *config.Manager) {
for {
cfg := m.Current()
t.probeAll(ctx, cfg)
select {
case <-ctx.Done():
return
case <-time.After(cfg.ProbeInterval()):
}
}
}
func (t *Tracker) probeAll(ctx context.Context, cfg *config.Config) {
var wg sync.WaitGroup
for i := range cfg.Servers {
wg.Add(1)
go func(s *config.Server) {
defer wg.Done()
t.record(s.Name, Probe(ctx, s, cfg.ConnectTimeout()))
}(&cfg.Servers[i])
}
wg.Wait()
names := map[string]bool{}
for _, s := range cfg.Servers {
names[s.Name] = true
}
t.mu.Lock()
for n := range t.st {
if !names[n] {
delete(t.st, n)
}
}
t.mu.Unlock()
}
// Result is the outcome of one probe.
type Result struct {
Status string
Latency time.Duration
Err error
}
// Probe tries each of the server's addresses in order and returns the first success.
func Probe(ctx context.Context, s *config.Server, timeout time.Duration) Result {
var last Result
for _, addr := range s.Addresses {
start := time.Now()
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
last = Result{Err: err}
continue
}
host, port := protocol.SplitHostPort(addr)
js, err := protocol.QueryStatus(conn, host, port, timeout)
conn.Close()
if err != nil {
last = Result{Err: err}
continue
}
return Result{Status: js, Latency: time.Since(start)}
}
return last
}
func (t *Tracker) record(name string, r Result) {
s := State{Name: name, Up: r.Err == nil, LastCheck: time.Now(), LatencyMS: r.Latency.Milliseconds(), status: r.Status}
if r.Err != nil {
s.Error = r.Err.Error()
} else {
var parsed struct {
Version struct{ Name string } `json:"version"`
Players struct{ Online, Max int } `json:"players"`
}
if json.Unmarshal([]byte(r.Status), &parsed) == nil {
s.Version, s.Online, s.Max = parsed.Version.Name, parsed.Players.Online, parsed.Players.Max
}
}
t.mu.Lock()
prev := t.st[name]
t.st[name] = &s
t.mu.Unlock()
if prev == nil || prev.Up != s.Up {
if s.Up {
t.log.Info("backend up", "server", name, "latency_ms", s.LatencyMS)
} else {
t.log.Warn("backend down", "server", name, "err", s.Error)
}
}
}