Files
Spinner/internal/admin/admin.go
T
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

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
}
var 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)
}
}