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