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,154 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user