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>
141 lines
4.4 KiB
Go
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)
|
|
}
|
|
}
|