Files
Spinner/internal/admin/admin.go
T
VeillaxandClaude Sonnet 5.5 4afbf2d266
CI / test (push) Successful in 54s
Release / release (push) Successful in 2m31s
Docker / image (push) Successful in 2m52s
Fix the Servers page failing when no servers are configured
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>
2026-10-26 23:58:26 -06:00

141 lines
4.4 KiB
Go

// Package admin serves the REST API, Prometheus metrics and the embedded UI.
package admin
import (
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"git.veillax.com/Veillax/spinner/internal/config"
"git.veillax.com/Veillax/spinner/internal/health"
"git.veillax.com/Veillax/spinner/internal/proxy"
"git.veillax.com/Veillax/spinner/web"
)
type Admin struct {
Cfg *config.Manager
Health *health.Tracker
Proxy *proxy.Proxy
Token string
}
func (a *Admin) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'")
w.Write(web.Index)
})
mux.HandleFunc("GET /api/overview", a.auth(a.overview))
mux.HandleFunc("GET /api/config", a.auth(a.getConfig))
mux.HandleFunc("PUT /api/config", a.auth(a.putConfig))
mux.HandleFunc("GET /metrics", a.auth(a.metrics))
return mux
}
func (a *Admin) auth(next http.HandlerFunc) http.HandlerFunc {
want := sha256.Sum256([]byte(a.Token))
return func(w http.ResponseWriter, r *http.Request) {
got := sha256.Sum256([]byte(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")))
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
w.Header().Set("WWW-Authenticate", `Bearer realm="spinner"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
type serverView struct {
config.Server
Health *health.State `json:"health,omitempty"`
Stats *proxy.ServerSnapshot `json:"stats,omitempty"`
}
func (a *Admin) overview(w http.ResponseWriter, r *http.Request) {
snap := a.Proxy.Stats.Snapshot()
states := map[string]health.State{}
for _, s := range a.Health.Snapshot() {
states[s.Name] = s
}
stats := map[string]proxy.ServerSnapshot{}
for _, s := range snap.Servers {
stats[s.Name] = s
}
servers := []serverView{}
for _, s := range a.Cfg.Current().Servers {
v := serverView{Server: s}
if h, ok := states[s.Name]; ok {
v.Health = &h
}
if st, ok := stats[s.Name]; ok {
v.Stats = &st
}
servers = append(servers, v)
}
snap.Servers = nil
writeJSON(w, map[string]any{"totals": snap, "servers": servers})
}
func (a *Admin) getConfig(w http.ResponseWriter, r *http.Request) { writeJSON(w, a.Cfg.Current()) }
func (a *Admin) putConfig(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
cfg, err := config.Parse(body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := a.Cfg.Save(cfg); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, cfg)
}
func esc(s string) string {
return strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`).Replace(s)
}
func (a *Admin) metrics(w http.ResponseWriter, r *http.Request) {
snap := a.Proxy.Stats.Snapshot()
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
p := func(format string, args ...any) { fmt.Fprintf(w, format+"\n", args...) }
p("# TYPE spinner_uptime_seconds gauge\nspinner_uptime_seconds %d", snap.UptimeSecs)
p("# TYPE spinner_connections_active gauge\nspinner_connections_active %d", snap.Active)
p("# TYPE spinner_connections_total counter\nspinner_connections_total %d", snap.Total)
p("# TYPE spinner_connections_rejected_total counter\nspinner_connections_rejected_total %d", snap.Rejected)
p("# TYPE spinner_unknown_host_total counter\nspinner_unknown_host_total %d", snap.Unknown)
for _, s := range snap.Servers {
l := fmt.Sprintf(`{server="%s"}`, esc(s.Name))
p("spinner_server_connections_active%s %d", l, s.Active)
p("spinner_server_connections_total%s %d", l, s.Total)
p("spinner_server_failures_total%s %d", l, s.Failures)
p("spinner_server_bytes_in_total%s %d", l, s.BytesIn)
p("spinner_server_bytes_out_total%s %d", l, s.BytesOut)
}
for _, h := range a.Health.Snapshot() {
up := 0
if h.Up {
up = 1
}
l := fmt.Sprintf(`{server="%s"}`, esc(h.Name))
p("spinner_server_up%s %d", l, up)
p("spinner_server_latency_ms%s %d", l, h.LatencyMS)
p("spinner_server_players_online%s %d", l, h.Online)
}
}