CI / test (push) Successful in 2m32s
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>
155 lines
3.7 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|