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