From 27916bbe6158c114cac18670ab76d49f8828f810 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 31 May 2026 19:50:15 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(admin-console):=20Stage=201=20?= =?UTF-8?q?=E2=80=94=20pipe=20+=20skeleton=20behind=20the=20gateway?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the server-rendered operator console at /_gm, exposed publicly through the gateway behind the existing admin_accounts Basic Auth. Backend: - new internal/adminconsole package (html/template Renderer, stateless HMAC CSRF signer, embedded stylesheet) - /_gm route group reusing basicauth.Middleware(admin.Service) + a CSRF guard (per-operator token + same-origin check); dashboard landing page - BACKEND_ADMIN_CONSOLE_CSRF_KEY config (per-process random fallback) Gateway: - new "admin" public route class (per-IP rate limit, body + GET/HEAD/POST method limits) classifying /_gm traffic - reverse proxy to the backend /_gm surface, preserving Host and relaying the backend 401 Basic Auth challenge; 502 when the backend is unreachable - GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_* config dev-deploy: - Caddy routes /_gm/* to the gateway - bootstrap admin + stable CSRF key; enable Prometheus /metrics exporters on backend and gateway (forward-compat for a future Prometheus/Grafana stack) Docs: ARCHITECTURE 14.1/16, FUNCTIONAL 10.2.1 (+ru mirror), backend and gateway READMEs, new backend/docs/admin-console.md. Tests: renderer + CSRF unit tests; backend router auth/render/asset/CSRF; gateway classifier, proxy forwarding/Host/401/405/413/429/502. --- backend/README.md | 9 +- backend/cmd/backend/main.go | 15 ++ backend/docs/admin-console.md | 90 ++++++++ .../internal/adminconsole/assets/console.css | 49 +++++ backend/internal/adminconsole/csrf.go | 54 +++++ backend/internal/adminconsole/csrf_test.go | 42 ++++ backend/internal/adminconsole/doc.go | 18 ++ backend/internal/adminconsole/render.go | 107 ++++++++++ backend/internal/adminconsole/render_test.go | 67 ++++++ .../adminconsole/templates/layout.gohtml | 28 +++ .../templates/pages/dashboard.gohtml | 22 ++ backend/internal/config/config.go | 14 ++ .../internal/server/handlers_admin_console.go | 154 ++++++++++++++ .../server/handlers_admin_console_test.go | 141 +++++++++++++ backend/internal/server/router.go | 26 +++ docs/ARCHITECTURE.md | 27 ++- docs/FUNCTIONAL.md | 16 ++ docs/FUNCTIONAL_ru.md | 17 ++ gateway/README.md | 24 +++ gateway/cmd/gateway/main.go | 9 + gateway/internal/config/config.go | 52 +++++ gateway/internal/restapi/admin_proxy.go | 49 +++++ gateway/internal/restapi/admin_proxy_test.go | 198 ++++++++++++++++++ gateway/internal/restapi/public_anti_abuse.go | 15 ++ gateway/internal/restapi/server.go | 21 ++ gateway/internal/restapi/server_test.go | 30 +++ tools/dev-deploy/Caddyfile.dev | 8 + tools/dev-deploy/docker-compose.yml | 20 +- 28 files changed, 1319 insertions(+), 3 deletions(-) create mode 100644 backend/docs/admin-console.md create mode 100644 backend/internal/adminconsole/assets/console.css create mode 100644 backend/internal/adminconsole/csrf.go create mode 100644 backend/internal/adminconsole/csrf_test.go create mode 100644 backend/internal/adminconsole/doc.go create mode 100644 backend/internal/adminconsole/render.go create mode 100644 backend/internal/adminconsole/render_test.go create mode 100644 backend/internal/adminconsole/templates/layout.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/dashboard.gohtml create mode 100644 backend/internal/server/handlers_admin_console.go create mode 100644 backend/internal/server/handlers_admin_console_test.go create mode 100644 gateway/internal/restapi/admin_proxy.go create mode 100644 gateway/internal/restapi/admin_proxy_test.go diff --git a/backend/README.md b/backend/README.md index 05ec325..69e7254 100644 --- a/backend/README.md +++ b/backend/README.md @@ -27,10 +27,16 @@ The implementation specification lives in `PLAN.md`. | ------------------ | ----------------------------------------------- | ------------------------------------- | | `/api/v1/public/*` | none | Registration, code confirmation | | `/api/v1/user/*` | `X-User-ID` injected by gateway | Authenticated end users | -| `/api/v1/admin/*` | HTTP Basic Auth against `admin_accounts` | Platform administrators | +| `/api/v1/admin/*` | HTTP Basic Auth against `admin_accounts` | Platform administrators (JSON) | +| `/_gm`, `/_gm/*` | HTTP Basic Auth against `admin_accounts` | Operator console (server-rendered HTML)| | `/healthz` | none | Liveness probe | | `/readyz` | none | Readiness probe | +The `/_gm` operator console is the human-facing surface for the admin +operations; it reuses the admin Basic Auth verifier, renders with +`html/template`, and is the only admin surface exposed publicly (through +the gateway). See `docs/admin-console.md`. + The full contract is documented in `openapi.yaml` and validated at runtime by the contract tests under `internal/server/`. @@ -100,6 +106,7 @@ fast. | `BACKEND_GAME_STATE_ROOT` | yes | — | Host directory bind-mounted into engine containers. | | `BACKEND_ADMIN_BOOTSTRAP_USER` | no | — | Initial admin username; idempotent insert. | | `BACKEND_ADMIN_BOOTSTRAP_PASSWORD` | no | — | Initial admin password; required if user is set. | +| `BACKEND_ADMIN_CONSOLE_CSRF_KEY` | no | random per-process | Secret keying the `/_gm` console CSRF token. Set a shared value across replicas; unset uses a per-process random key (forms reset on restart). | | `BACKEND_GEOIP_DB_PATH` | yes | — | Filesystem path to GeoLite2 Country `.mmdb`. | | `BACKEND_OTEL_TRACES_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`. | | `BACKEND_OTEL_METRICS_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`, `prometheus`. | diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 966f1c9..25acf9c 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -22,6 +22,7 @@ import ( _ "time/tzdata" "galaxy/backend/internal/admin" + "galaxy/backend/internal/adminconsole" "galaxy/backend/internal/app" "galaxy/backend/internal/auth" "galaxy/backend/internal/config" @@ -356,6 +357,19 @@ func run(ctx context.Context) (err error) { userGamesHandlers := backendserver.NewUserGamesHandlers(runtimeSvc, engineCli, logger) userMailHandlers := backendserver.NewUserMailHandlers(diplomailSvc, lobbySvc, userSvc, logger) + var consoleCSRF *adminconsole.CSRF + if cfg.AdminConsole.CSRFKey != "" { + consoleCSRF = adminconsole.NewCSRF([]byte(cfg.AdminConsole.CSRFKey)) + } else { + consoleCSRF, err = adminconsole.NewRandomCSRF() + if err != nil { + return fmt.Errorf("init admin console CSRF: %w", err) + } + logger.Warn("admin console CSRF key not set; using a per-process random key (forms reset on restart, not valid across replicas)", + zap.String("env", "BACKEND_ADMIN_CONSOLE_CSRF_KEY")) + } + adminConsoleHandlers := backendserver.NewAdminConsoleHandlers(adminconsole.MustNewRenderer(), consoleCSRF, logger) + ready := func() bool { return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready() } @@ -388,6 +402,7 @@ func run(ctx context.Context) (err error) { AdminGeo: adminGeoHandlers, UserGames: userGamesHandlers, UserMail: userMailHandlers, + AdminConsole: adminConsoleHandlers, }) if err != nil { return fmt.Errorf("build backend router: %w", err) diff --git a/backend/docs/admin-console.md b/backend/docs/admin-console.md new file mode 100644 index 0000000..801fa84 --- /dev/null +++ b/backend/docs/admin-console.md @@ -0,0 +1,90 @@ +# Operator console (`/_gm`) + +The operator console is a server-rendered web UI for the platform's admin +operations. It is the human-facing counterpart to the JSON admin API under +`/api/v1/admin/*`: both call the same service layer, but the console renders +HTML pages an operator drives in a browser, while the JSON API stays internal +to the deployment for programmatic and test use. + +## Design choices + +- **Server-rendered, no client framework.** Pages are rendered with the + standard library's `html/template`. Navigation is by ordinary links and + query parameters; every state change is an HTML form `POST` answered with a + Post/Redirect/Get redirect. There is no build step, no JavaScript framework, + and no separate asset pipeline — a single embedded stylesheet under + `/_gm/assets/`. +- **Reuses the existing admin auth.** The console mounts behind the same + `basicauth.Middleware(admin.Service)` verifier that gates `/api/v1/admin/*`, + so there is one credential store (`admin_accounts`, bcrypt-12) and no second + secret to manage. +- **Lives in the backend.** The backend owns the admin domain and the data, so + rendering there lets the console call the service layer directly. The gateway + stays a thin proxy. + +## Request path + +``` +Browser ── /_gm/* ──► edge Caddy ──► gateway (public listener) + gateway: anti-abuse `admin` class (per-IP rate limit, body + method limits) + └─► reverse proxy ──► backend /_gm/* + backend: basicauth.Middleware(admin.Service) + └─► CSRF guard (state-changing methods) + └─► console handler ──► admin service layer ──► html/template +``` + +The gateway preserves the inbound `Host` and relays the backend's `401` Basic +Auth challenge unchanged, so the browser shows its native credential dialog. +The gateway adds only the edge anti-abuse layer; authentication and every state +change are enforced by the backend. The gateway answers `502` when the backend +is unreachable. See the gateway README "Operator Console Proxy" section for the +`admin` route-class env vars. + +## Components (package `internal/adminconsole`) + +The package is framework-agnostic (no gin) so it unit-tests in isolation: + +- `Renderer` — parses the embedded layout plus one content page per route and + renders a named page wrapped in the shared layout. Rendering goes through an + intermediate buffer, so a template failure never emits a partial document. +- `CSRF` — issues and verifies the stateless anti-CSRF token: HMAC-SHA256 over + the authenticated username, keyed by `BACKEND_ADMIN_CONSOLE_CSRF_KEY`. When + the key is unset a per-process random key is used (secure, but forms reset on + restart and do not validate across replicas — set a shared key for + multi-replica deployments). +- `Assets` — the embedded stylesheet filesystem served under `/_gm/assets/`. + +The gin glue (route group, Basic Auth, the CSRF guard middleware, the per-page +handlers) lives in `internal/server/handlers_admin_console.go` and +`internal/server/router.go` (`registerAdminConsoleRoutes`). + +## CSRF protection + +Because the console is sessionless (HTTP Basic Auth, whose credentials the +browser replays automatically), state-changing requests are double-guarded: + +1. A stateless per-operator token (`_csrf` form field) that a cross-site page + cannot read or forge. +2. A same-origin `Origin`/`Referer` check (when the browser sends one), which + relies on the gateway preserving the inbound `Host`. + +Safe methods (`GET`/`HEAD`/`OPTIONS`) pass without a token. + +## Monitoring + +The dashboard is the console landing page. It surfaces backend-visible +operational state — service health, game-runtime status, and queue depths — +read through the existing service and persistence layers. Richer cross-service +metrics are out of scope for the console itself: the `/metrics` Prometheus +exporters on `backend` and `gateway` are wired and enabled in the dev +deployment so a future Prometheus + Grafana stack can scrape them without code +changes. + +## Configuration + +| Variable | Where | Notes | +| --------------------------------- | ------- | ------------------------------------------------------------ | +| `BACKEND_ADMIN_CONSOLE_CSRF_KEY` | backend | CSRF token key; unset → per-process random key. | +| `BACKEND_ADMIN_BOOTSTRAP_USER` | backend | Bootstrap operator account (shared with the JSON admin API). | +| `BACKEND_ADMIN_BOOTSTRAP_PASSWORD`| backend | Bootstrap operator password. | +| `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_*` | gateway | `admin` route-class rate-limit and body budgets. | diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css new file mode 100644 index 0000000..bc29129 --- /dev/null +++ b/backend/internal/adminconsole/assets/console.css @@ -0,0 +1,49 @@ +/* Admin console stylesheet. Deliberately small and dependency-free: the + console is an internal operator tool, not a public surface. */ +:root { + --bg: #11151c; + --panel: #1b2230; + --panel-hi: #232c3d; + --ink: #e6ebf2; + --ink-dim: #9aa7ba; + --line: #2c3850; + --accent: #5aa9ff; + --danger: #ff6b6b; + --ok: #4ecb8d; +} +* { box-sizing: border-box; } +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font: 15px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +.topbar { + display: flex; + align-items: center; + gap: 1.5rem; + padding: 0.6rem 1.2rem; + background: var(--panel); + border-bottom: 1px solid var(--line); +} +.topbar .brand { font-weight: 700; letter-spacing: 0.04em; } +.topbar .mainnav { display: flex; gap: 1rem; flex: 1; } +.topbar .mainnav a.active { color: var(--ink); border-bottom: 2px solid var(--accent); } +.topbar .who { color: var(--ink-dim); } +.content { padding: 1.5rem; max-width: 1100px; margin: 0 auto; } +h1 { font-size: 1.4rem; margin: 0 0 0.4rem; } +.lede { color: var(--ink-dim); margin-top: 0; } +.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-top: 1.5rem; } +.card { + display: block; + padding: 1rem 1.2rem; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + color: var(--ink); +} +.card:hover { background: var(--panel-hi); text-decoration: none; } +.card h2 { font-size: 1.05rem; margin: 0 0 0.3rem; color: var(--accent); } +.card p { margin: 0; color: var(--ink-dim); font-size: 0.9rem; } diff --git a/backend/internal/adminconsole/csrf.go b/backend/internal/adminconsole/csrf.go new file mode 100644 index 0000000..a0317a1 --- /dev/null +++ b/backend/internal/adminconsole/csrf.go @@ -0,0 +1,54 @@ +package adminconsole + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" +) + +// CSRF issues and verifies the stateless anti-CSRF token used by the admin +// console. The token is an HMAC-SHA256 over the authenticated operator's +// username keyed by a process secret, so a cross-site request cannot forge it +// without already being able to read an authenticated page. The console is +// sessionless (HTTP Basic Auth), which makes a stateless, per-operator token +// the natural fit. +type CSRF struct { + key []byte +} + +// NewCSRF returns a CSRF signer keyed by key. A shared key across backend +// replicas lets a form rendered by one replica validate on another; callers +// that pass a per-process random key (see NewRandomCSRF) accept that forms do +// not survive a restart or span replicas. +func NewCSRF(key []byte) *CSRF { + return &CSRF{key: key} +} + +// NewRandomCSRF returns a CSRF signer keyed by a fresh 32-byte random secret. +// It is the secure default when no shared key is configured. +func NewRandomCSRF() (*CSRF, error) { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("generate admin console CSRF key: %w", err) + } + return &CSRF{key: key}, nil +} + +// Token returns the anti-CSRF token bound to username. +func (c *CSRF) Token(username string) string { + mac := hmac.New(sha256.New, c.key) + mac.Write([]byte(username)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +// Verify reports whether token is the valid anti-CSRF token for username. The +// comparison runs in constant time relative to the token bytes. +func (c *CSRF) Verify(username, token string) bool { + if token == "" { + return false + } + expected := c.Token(username) + return hmac.Equal([]byte(token), []byte(expected)) +} diff --git a/backend/internal/adminconsole/csrf_test.go b/backend/internal/adminconsole/csrf_test.go new file mode 100644 index 0000000..5e13f3a --- /dev/null +++ b/backend/internal/adminconsole/csrf_test.go @@ -0,0 +1,42 @@ +package adminconsole + +import "testing" + +func TestCSRFTokenRoundTrip(t *testing.T) { + signer := NewCSRF([]byte("shared-secret")) + token := signer.Token("alice") + + if !signer.Verify("alice", token) { + t.Fatal("valid token rejected") + } + if signer.Verify("bob", token) { + t.Fatal("token accepted for a different operator") + } + if signer.Verify("alice", "") { + t.Fatal("empty token accepted") + } + if signer.Verify("alice", token+"x") { + t.Fatal("tampered token accepted") + } +} + +func TestCSRFKeySeparation(t *testing.T) { + a := NewCSRF([]byte("key-a")) + b := NewCSRF([]byte("key-b")) + if a.Token("operator") == b.Token("operator") { + t.Fatal("tokens collide across distinct keys") + } + if b.Verify("operator", a.Token("operator")) { + t.Fatal("token minted under one key verified under another") + } +} + +func TestRandomCSRFRoundTrip(t *testing.T) { + signer, err := NewRandomCSRF() + if err != nil { + t.Fatalf("NewRandomCSRF: %v", err) + } + if !signer.Verify("operator", signer.Token("operator")) { + t.Fatal("random-key token failed to round-trip") + } +} diff --git a/backend/internal/adminconsole/doc.go b/backend/internal/adminconsole/doc.go new file mode 100644 index 0000000..dbf6092 --- /dev/null +++ b/backend/internal/adminconsole/doc.go @@ -0,0 +1,18 @@ +// Package adminconsole renders the server-side operator console mounted by the +// backend under the `/_gm` route group. +// +// The console is a multi-page, server-rendered surface built on the standard +// library's html/template package: navigation is driven by request path and +// query, state changes are submitted with HTML forms and answered with a +// Post/Redirect/Get redirect. The package owns three concerns and nothing +// transport-specific: +// +// - Renderer composes the shared layout with one content page per route. +// - CSRF issues and verifies the stateless anti-CSRF token embedded in every +// state-changing form. +// - Assets exposes the embedded stylesheet served under `/_gm/assets/`. +// +// The gin glue (route registration, Basic Auth, the CSRF guard middleware, and +// the per-page handlers) lives in package server; this package stays free of +// the web framework so it can be unit-tested in isolation. +package adminconsole diff --git a/backend/internal/adminconsole/render.go b/backend/internal/adminconsole/render.go new file mode 100644 index 0000000..fac0a53 --- /dev/null +++ b/backend/internal/adminconsole/render.go @@ -0,0 +1,107 @@ +package adminconsole + +import ( + "bytes" + "embed" + "fmt" + "html/template" + "io" + "io/fs" + "path" + "strings" +) + +//go:embed templates +var templatesFS embed.FS + +//go:embed assets +var assetsFS embed.FS + +// Renderer holds the parsed admin console templates. It composes one template +// set per content page, each combining the shared layout (defining the page +// chrome and the "layout" entry template) with that page's "content" block, so +// rendering a page is a single ExecuteTemplate call against the "layout" name. +type Renderer struct { + pages map[string]*template.Template +} + +// PageData is the view model passed to every admin console page. Title is the +// document title; Username is the authenticated operator; CSRFToken is the +// per-operator token embedded into state-changing forms; ActiveNav marks the +// highlighted navigation entry; Data carries the page-specific payload. +type PageData struct { + Title string + Username string + CSRFToken string + ActiveNav string + Data any +} + +// NewRenderer parses the embedded layout and every content page under +// templates/pages, returning a Renderer ready to serve them. It fails when a +// template cannot be parsed. +func NewRenderer() (*Renderer, error) { + base, err := template.New("layout").ParseFS(templatesFS, "templates/layout.gohtml") + if err != nil { + return nil, fmt.Errorf("parse admin console layout: %w", err) + } + + pageFiles, err := fs.Glob(templatesFS, "templates/pages/*.gohtml") + if err != nil { + return nil, fmt.Errorf("enumerate admin console pages: %w", err) + } + if len(pageFiles) == 0 { + return nil, fmt.Errorf("admin console: no page templates found under templates/pages") + } + + pages := make(map[string]*template.Template, len(pageFiles)) + for _, file := range pageFiles { + name := strings.TrimSuffix(path.Base(file), ".gohtml") + clone, err := base.Clone() + if err != nil { + return nil, fmt.Errorf("clone admin console layout for %q: %w", name, err) + } + if _, err := clone.ParseFS(templatesFS, file); err != nil { + return nil, fmt.Errorf("parse admin console page %q: %w", name, err) + } + pages[name] = clone + } + + return &Renderer{pages: pages}, nil +} + +// MustNewRenderer is like NewRenderer but panics on error. The templates are +// embedded at build time, so a parse failure is a programmer error rather than +// a runtime condition. +func MustNewRenderer() *Renderer { + renderer, err := NewRenderer() + if err != nil { + panic(err) + } + return renderer +} + +// Render writes the named page, wrapped in the shared layout, to w using data. +// It returns an error when page is unknown or template execution fails; the +// page is rendered into an intermediate buffer first so a mid-render failure +// never emits a partial document to w. +func (r *Renderer) Render(w io.Writer, page string, data PageData) error { + tmpl, ok := r.pages[page] + if !ok { + return fmt.Errorf("admin console: unknown page %q", page) + } + + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil { + return fmt.Errorf("render admin console page %q: %w", page, err) + } + + _, err := buf.WriteTo(w) + return err +} + +// Assets returns the embedded static asset tree rooted at the assets directory, +// suitable for serving under `/_gm/assets/`. +func Assets() (fs.FS, error) { + return fs.Sub(assetsFS, "assets") +} diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go new file mode 100644 index 0000000..d0cde20 --- /dev/null +++ b/backend/internal/adminconsole/render_test.go @@ -0,0 +1,67 @@ +package adminconsole + +import ( + "bytes" + "io/fs" + "strings" + "testing" +) + +func TestRendererRendersDashboard(t *testing.T) { + renderer, err := NewRenderer() + if err != nil { + t.Fatalf("NewRenderer: %v", err) + } + + var buf bytes.Buffer + err = renderer.Render(&buf, "dashboard", PageData{ + Title: "Dashboard", + Username: "ops-bob", + ActiveNav: "dashboard", + }) + if err != nil { + t.Fatalf("Render: %v", err) + } + + out := buf.String() + for _, want := range []string{ + "", + "Dashboard", + "ops-bob", + `href="/_gm/users"`, + "/_gm/assets/console.css", + } { + if !strings.Contains(out, want) { + t.Errorf("rendered page missing %q\n--- page ---\n%s", want, out) + } + } +} + +func TestRendererUnknownPage(t *testing.T) { + renderer := MustNewRenderer() + if err := renderer.Render(&bytes.Buffer{}, "does-not-exist", PageData{}); err == nil { + t.Fatal("expected an error rendering an unknown page") + } +} + +func TestRendererEscapesUsername(t *testing.T) { + renderer := MustNewRenderer() + + var buf bytes.Buffer + if err := renderer.Render(&buf, "dashboard", PageData{Username: ""}); err != nil { + t.Fatalf("Render: %v", err) + } + if strings.Contains(buf.String(), "") { + t.Error("username was not HTML-escaped in the rendered page") + } +} + +func TestAssetsContainsStylesheet(t *testing.T) { + fsys, err := Assets() + if err != nil { + t.Fatalf("Assets: %v", err) + } + if _, err := fs.Stat(fsys, "console.css"); err != nil { + t.Fatalf("console.css missing from embedded assets: %v", err) + } +} diff --git a/backend/internal/adminconsole/templates/layout.gohtml b/backend/internal/adminconsole/templates/layout.gohtml new file mode 100644 index 0000000..8634190 --- /dev/null +++ b/backend/internal/adminconsole/templates/layout.gohtml @@ -0,0 +1,28 @@ +{{define "layout" -}} + + + + + + +{{.Title}} · Galaxy GM + + + +
+ Galaxy · GM + + {{.Username}} +
+
+{{template "content" .}} +
+ + +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/dashboard.gohtml b/backend/internal/adminconsole/templates/pages/dashboard.gohtml new file mode 100644 index 0000000..cef9d9f --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/dashboard.gohtml @@ -0,0 +1,22 @@ +{{define "content" -}} +

Dashboard

+

Signed in as {{.Username}}.

+
+ +

Users

+

Accounts, sanctions, entitlements, soft-delete.

+
+ +

Games & runtimes

+

Lobby state, engine versions, turn control.

+
+ +

Operators

+

Admin accounts: create, disable, reset password.

+
+ +

Mail & notifications

+

Deliveries, dead-letters, broadcasts.

+
+
+{{- end}} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index ee6cae9..baf6c7b 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -55,6 +55,8 @@ const ( envAdminBootstrapUser = "BACKEND_ADMIN_BOOTSTRAP_USER" envAdminBootstrapPassword = "BACKEND_ADMIN_BOOTSTRAP_PASSWORD" + envAdminConsoleCSRFKey = "BACKEND_ADMIN_CONSOLE_CSRF_KEY" + envGeoIPDBPath = "BACKEND_GEOIP_DB_PATH" envOTelTracesExporter = "BACKEND_OTEL_TRACES_EXPORTER" @@ -208,6 +210,7 @@ type Config struct { Docker DockerConfig Game GameConfig Admin AdminBootstrapConfig + AdminConsole AdminConsoleConfig GeoIP GeoIPConfig Telemetry TelemetryConfig Auth AuthConfig @@ -308,6 +311,15 @@ type AdminBootstrapConfig struct { Password string } +// AdminConsoleConfig configures the server-rendered operator console. +// CSRFKey is the secret keying the console's stateless anti-CSRF token. +// When empty the console falls back to a per-process random key, which is +// secure but means forms do not survive a restart and do not validate across +// replicas; set a shared key when running more than one backend instance. +type AdminConsoleConfig struct { + CSRFKey string +} + // GeoIPConfig configures the GeoLite2 country database used by geo lookups. type GeoIPConfig struct { DBPath string @@ -644,6 +656,8 @@ func LoadFromEnv() (Config, error) { cfg.Admin.User = loadString(envAdminBootstrapUser, cfg.Admin.User) cfg.Admin.Password = loadString(envAdminBootstrapPassword, cfg.Admin.Password) + cfg.AdminConsole.CSRFKey = loadString(envAdminConsoleCSRFKey, cfg.AdminConsole.CSRFKey) + cfg.GeoIP.DBPath = loadString(envGeoIPDBPath, cfg.GeoIP.DBPath) cfg.Telemetry.TracesExporter = strings.ToLower(loadString(envOTelTracesExporter, cfg.Telemetry.TracesExporter)) diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go new file mode 100644 index 0000000..fd9b6b5 --- /dev/null +++ b/backend/internal/server/handlers_admin_console.go @@ -0,0 +1,154 @@ +package server + +import ( + "bytes" + "net/http" + "net/url" + "strings" + + "galaxy/backend/internal/adminconsole" + "galaxy/backend/internal/server/httperr" + "galaxy/backend/internal/server/middleware/basicauth" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// AdminConsoleHandlers renders the server-side operator console mounted under +// the `/_gm` route group. It wraps the framework-agnostic +// adminconsole.Renderer and CSRF signer with the gin glue: the per-page +// handlers, the embedded static-asset handler, and the CSRF guard middleware +// applied to state-changing requests. Authentication is provided by the shared +// admin Basic Auth middleware mounted on the group, so this type assumes the +// caller has already been verified. +type AdminConsoleHandlers struct { + renderer *adminconsole.Renderer + csrf *adminconsole.CSRF + assets http.Handler + logger *zap.Logger +} + +// NewAdminConsoleHandlers constructs the console handler set. A nil renderer +// falls back to the embedded default templates; a nil csrf falls back to a +// fresh per-process random key; a nil logger falls back to zap.NewNop. It +// panics only on conditions that are unrecoverable at startup (template parse +// failure or crypto/rand failure), both of which indicate a broken build or +// host rather than a runtime input. +func NewAdminConsoleHandlers(renderer *adminconsole.Renderer, csrf *adminconsole.CSRF, logger *zap.Logger) *AdminConsoleHandlers { + if logger == nil { + logger = zap.NewNop() + } + if renderer == nil { + renderer = adminconsole.MustNewRenderer() + } + if csrf == nil { + generated, err := adminconsole.NewRandomCSRF() + if err != nil { + panic(err) + } + csrf = generated + } + + assetsFS, err := adminconsole.Assets() + if err != nil { + panic(err) + } + + return &AdminConsoleHandlers{ + renderer: renderer, + csrf: csrf, + assets: http.StripPrefix("/_gm/assets/", http.FileServer(http.FS(assetsFS))), + logger: logger.Named("http.admin.console"), + } +} + +// Dashboard renders the console landing page (GET /_gm and GET /_gm/). +func (h *AdminConsoleHandlers) Dashboard() gin.HandlerFunc { + return func(c *gin.Context) { + h.render(c, http.StatusOK, "dashboard", "dashboard", "Dashboard", nil) + } +} + +// Asset serves the embedded console static assets under `/_gm/assets/`. +func (h *AdminConsoleHandlers) Asset() gin.HandlerFunc { + return gin.WrapH(h.assets) +} + +// RequireCSRF returns middleware guarding state-changing requests against +// cross-site request forgery. Safe methods pass through untouched. For unsafe +// methods it requires both a same-origin Origin/Referer header (when the +// browser sends one) and a valid per-operator token in the `_csrf` form field; +// either check failing yields 403. +func (h *AdminConsoleHandlers) RequireCSRF() gin.HandlerFunc { + return func(c *gin.Context) { + if isSafeHTTPMethod(c.Request.Method) { + c.Next() + return + } + if !sameOriginRequest(c.Request) { + httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "cross-origin request rejected") + return + } + username, _ := basicauth.UsernameFromContext(c.Request.Context()) + if !h.csrf.Verify(username, c.PostForm("_csrf")) { + httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "invalid or missing CSRF token") + return + } + c.Next() + } +} + +// render composes the data common to every console page (operator name, CSRF +// token, active navigation entry) and writes the named page. It renders into an +// intermediate buffer so a template failure surfaces as a clean 500 without +// emitting a partial document. +func (h *AdminConsoleHandlers) render(c *gin.Context, status int, page, activeNav, title string, data any) { + username, _ := basicauth.UsernameFromContext(c.Request.Context()) + + var buf bytes.Buffer + err := h.renderer.Render(&buf, page, adminconsole.PageData{ + Title: title, + Username: username, + CSRFToken: h.csrf.Token(username), + ActiveNav: activeNav, + Data: data, + }) + if err != nil { + h.logger.Error("render admin console page", zap.String("page", page), zap.Error(err)) + httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "failed to render page") + return + } + + c.Data(status, "text/html; charset=utf-8", buf.Bytes()) +} + +// isSafeHTTPMethod reports whether method is a read-only HTTP method that the +// CSRF guard may let through without a token. +func isSafeHTTPMethod(method string) bool { + switch method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return true + default: + return false + } +} + +// sameOriginRequest reports whether the request's Origin (or, failing that, +// Referer) names the same host as the request itself. A request that carries +// neither header is treated as same-origin, leaving the CSRF token as the sole +// guard; a malformed or cross-host value is rejected. This relies on the +// gateway reverse proxy preserving the inbound Host header. +func sameOriginRequest(r *http.Request) bool { + source := r.Header.Get("Origin") + if source == "" { + source = r.Header.Get("Referer") + } + if source == "" { + return true + } + parsed, err := url.Parse(source) + if err != nil || parsed.Host == "" { + return false + } + return strings.EqualFold(parsed.Host, r.Host) +} diff --git a/backend/internal/server/handlers_admin_console_test.go b/backend/internal/server/handlers_admin_console_test.go new file mode 100644 index 0000000..1c99fc1 --- /dev/null +++ b/backend/internal/server/handlers_admin_console_test.go @@ -0,0 +1,141 @@ +package server + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "galaxy/backend/internal/adminconsole" + "galaxy/backend/internal/server/middleware/basicauth" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func newConsoleTestRouter(t *testing.T) http.Handler { + t.Helper() + handler, err := NewRouter(RouterDependencies{ + Logger: zap.NewNop(), + AdminVerifier: basicauth.NewStaticVerifier("secret"), + AdminConsole: NewAdminConsoleHandlers(nil, adminconsole.NewCSRF([]byte("test-key")), nil), + }) + if err != nil { + t.Fatalf("NewRouter: %v", err) + } + return handler +} + +func TestAdminConsoleRequiresAuth(t *testing.T) { + router := newConsoleTestRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/_gm/", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + if got := rec.Header().Get("WWW-Authenticate"); !strings.Contains(got, "Basic") { + t.Fatalf("WWW-Authenticate = %q, want a Basic challenge", got) + } +} + +func TestAdminConsoleDashboardRenders(t *testing.T) { + router := newConsoleTestRouter(t) + + for _, path := range []string{"/_gm", "/_gm/"} { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET %s status = %d, want 200; body=%s", path, rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("GET %s content-type = %q, want text/html", path, ct) + } + body := rec.Body.String() + if !strings.Contains(body, "Dashboard") { + t.Errorf("GET %s body missing the dashboard heading", path) + } + if !strings.Contains(body, "ops") { + t.Errorf("GET %s body missing the operator name", path) + } + } +} + +func TestAdminConsoleServesAsset(t *testing.T) { + router := newConsoleTestRouter(t) + + req := httptest.NewRequest(http.MethodGet, "/_gm/assets/console.css", nil) + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("asset status = %d, want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/css") { + t.Errorf("asset content-type = %q, want text/css", ct) + } +} + +func TestAdminConsoleRequireCSRF(t *testing.T) { + gin.SetMode(gin.TestMode) + + csrf := adminconsole.NewCSRF([]byte("test-key")) + console := NewAdminConsoleHandlers(nil, csrf, nil) + + engine := gin.New() + engine.Use(func(c *gin.Context) { + c.Request = c.Request.WithContext(basicauth.WithUsername(c.Request.Context(), "ops")) + c.Next() + }) + engine.Use(console.RequireCSRF()) + engine.GET("/x", func(c *gin.Context) { c.Status(http.StatusOK) }) + engine.POST("/x", func(c *gin.Context) { c.Status(http.StatusOK) }) + + token := csrf.Token("ops") + + cases := []struct { + name string + method string + form string + origin string + host string + want int + }{ + {"get is a safe method", http.MethodGet, "", "", "galaxy.lan", http.StatusOK}, + {"valid token, same origin", http.MethodPost, "_csrf=" + token, "https://galaxy.lan", "galaxy.lan", http.StatusOK}, + {"valid token, no origin header", http.MethodPost, "_csrf=" + token, "", "galaxy.lan", http.StatusOK}, + {"missing token", http.MethodPost, "", "https://galaxy.lan", "galaxy.lan", http.StatusForbidden}, + {"wrong token", http.MethodPost, "_csrf=bogus", "https://galaxy.lan", "galaxy.lan", http.StatusForbidden}, + {"cross-origin", http.MethodPost, "_csrf=" + token, "https://evil.example", "galaxy.lan", http.StatusForbidden}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var body io.Reader + if tc.form != "" { + body = strings.NewReader(tc.form) + } + req := httptest.NewRequest(tc.method, "/x", body) + if tc.form != "" { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + if tc.origin != "" { + req.Header.Set("Origin", tc.origin) + } + req.Host = tc.host + + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + + if rec.Code != tc.want { + t.Fatalf("status = %d, want %d (body=%s)", rec.Code, tc.want, rec.Body.String()) + } + }) + } +} diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index d26b339..960e859 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -81,6 +81,13 @@ type RouterDependencies struct { AdminGeo *AdminGeoHandlers InternalSessions *InternalSessionsHandlers InternalUsers *InternalUsersHandlers + + // AdminConsole, when non-nil, mounts the server-rendered operator + // console under the `/_gm` route group behind the same admin Basic + // Auth verifier as `/api/v1/admin`. A nil value leaves the console + // unmounted, which keeps routers built without console wiring (the + // contract test, most unit tests) unchanged. + AdminConsole *AdminConsoleHandlers } // NewRouter constructs the backend gin engine wired with the documented @@ -123,6 +130,7 @@ func NewRouter(deps RouterDependencies) (http.Handler, error) { registerUserRoutes(router, instruments, deps) registerAdminRoutes(router, instruments, deps) registerInternalRoutes(router, instruments, deps) + registerAdminConsoleRoutes(router, deps) router.NoMethod(func(c *gin.Context) { if allow := allowedMethodsForPath(c.Request.URL.Path); allow != "" { @@ -364,6 +372,24 @@ func registerInternalRoutes(router *gin.Engine, instruments *metrics.Instruments users.GET("/:user_id/account-internal", deps.InternalUsers.GetAccountInternal()) } +// registerAdminConsoleRoutes mounts the server-rendered operator console under +// `/_gm` when deps.AdminConsole is wired. The group reuses the same admin Basic +// Auth verifier as `/api/v1/admin`; the CSRF guard then protects every +// state-changing request. A nil AdminConsole leaves the surface unmounted. +func registerAdminConsoleRoutes(router *gin.Engine, deps RouterDependencies) { + if deps.AdminConsole == nil { + return + } + + group := router.Group("/_gm") + group.Use(basicauth.Middleware(deps.AdminVerifier, adminBasicAuthRealm)) + group.Use(deps.AdminConsole.RequireCSRF()) + + group.GET("/assets/*filepath", deps.AdminConsole.Asset()) + group.GET("", deps.AdminConsole.Dashboard()) + group.GET("/", deps.AdminConsole.Dashboard()) +} + // allowedMethodsForPath returns the comma-separated list of methods // the gin router accepts on requestPath. Only the probe paths declare // a non-empty list so NoMethod can advertise a useful `Allow` header diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3a7f321..c5096d1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -581,6 +581,30 @@ directly. `/api/v1/admin/notifications/*`) reuse the per-domain logic of the module they target. +### 14.1 Operator console (`/_gm`) + +`backend` also serves a server-rendered operator console under the `/_gm` +route group — the human-facing surface for the admin operations otherwise +exposed as JSON under `/api/v1/admin/*`. It reuses the `admin_accounts` +Basic Auth verifier and renders pages with the standard library's +`html/template` (navigation by path and query, Post/Redirect/Get on +writes; no client framework or build step). + +Unlike the internal-only JSON admin API, the console is reachable from the +public edge: Caddy routes `/_gm/*` to the gateway public listener, which +classifies it as the `admin` anti-abuse class (per-IP rate limit, body and +method limits) and reverse-proxies it to `backend`'s `/_gm` surface. The +gateway preserves the inbound `Host` and relays the backend's 401 Basic +Auth challenge unchanged, so the browser shows its native credential +dialog. Authentication is enforced by `backend`; the gateway contributes +only the edge anti-abuse layer. + +State-changing requests are guarded against CSRF by a stateless token +(HMAC-SHA256 over the authenticated username, keyed by +`BACKEND_ADMIN_CONSOLE_CSRF_KEY`; a per-process random key is used when the +variable is unset) plus a same-origin `Origin`/`Referer` check. See +`backend/docs/admin-console.md` for the console design. + ## 15. Transport Security Model (gateway boundary) This section describes the secure exchange model between client and @@ -823,7 +847,8 @@ business validation and authorisation. | Session revocation propagation | backend → gateway | `session_invalidation` over the gRPC push stream flips the gateway-side cache entry to revoked and closes any active push stream. | | Authorisation, ownership, state transitions | backend | `X-User-ID` is the sole identity input on the user surface. | | Edge rate limiting | gateway | Backend has no rate-limit responsibility in MVP. | -| Admin authentication | backend | Basic Auth against `admin_accounts`. | +| Admin authentication | backend | Basic Auth against `admin_accounts`; the `/_gm` operator console reuses the same verifier. | +| Admin console CSRF | backend | Stateless HMAC token (`BACKEND_ADMIN_CONSOLE_CSRF_KEY`) + same-origin `Origin`/`Referer` check on `/_gm` writes. | | Engine API authentication | network | Engine listens only on the trusted network; backend is the only caller. | ### Backend ↔ Gateway trust diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 6eace12..5ec8a75 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -1162,6 +1162,22 @@ operator's password manager can match it across deployments. After the first deployment, the bootstrap password should be rotated through the admin surface. +### 10.2.1 Operator console (`/_gm`) + +Administrators drive these operations either programmatically through +the JSON admin API or through a server-rendered web console at `/_gm`. +The console authenticates with the same Basic Auth credentials: opening +any `/_gm` page prompts the browser's native credential dialog, and the +operator stays signed in for the session. Navigation is by ordinary +links and query parameters; every change is submitted as a form and +answered with a redirect back to the affected page. + +The console is the only admin surface reachable from outside the trusted +network. It is fronted by the gateway, so it inherits the same edge rate +limiting and request limits as the public API, and it carries an +anti-CSRF token on every change. The JSON admin API stays internal to +the deployment. + ### 10.3 Admin account management Existing admins can list other admins, create new ones, look up a diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 756d1e4..93c2490 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -1197,6 +1197,23 @@ deployments. После первого деплоя bootstrap-пароль должен быть ротирован через admin-surface. +### 10.2.1 Операторская консоль (`/_gm`) + +Администраторы выполняют эти операции либо программно через JSON +admin-API, либо через серверно-рендеримую веб-консоль на `/_gm`. +Консоль аутентифицируется теми же Basic Auth-учётными данными: +открытие любой страницы `/_gm` вызывает нативный диалог браузера для +ввода учётных данных, и оператор остаётся залогинен на время сессии. +Навигация — обычными ссылками и query-параметрами; каждое изменение +отправляется формой и завершается редиректом обратно на затронутую +страницу. + +Консоль — единственная admin-поверхность, достижимая извне +доверенной сети. Она проксируется через gateway, поэтому наследует те +же edge-rate-limiting и лимиты запросов, что и публичный API, и несёт +анти-CSRF-токен на каждом изменении. JSON admin-API остаётся +внутренним для деплоя. + ### 10.3 Управление admin-аккаунтами Существующие админы могут перечислять других админов, создавать diff --git a/gateway/README.md b/gateway/README.md index 28fb905..64f4c61 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -178,6 +178,30 @@ bootstrap or asset traffic through a pluggable public handler or proxy. That traffic belongs to dedicated public route classes and must not share rate limit buckets or abuse counters with the public auth API. +### Operator Console Proxy (`/_gm`) + +The gateway also fronts the backend operator console. The edge Caddy routes +`/_gm` and `/_gm/*` to this public listener; the gateway classifies that +traffic as the `admin` public route class and reverse-proxies it to the +backend at `GATEWAY_BACKEND_HTTP_URL`, preserving the request path and the +inbound `Host` header (so the backend's same-origin CSRF check observes the +public host). + +Authentication is delegated entirely to the backend (HTTP Basic Auth against +`admin_accounts`): the backend's `401` challenge is relayed unchanged so the +browser shows its native credential dialog. The gateway contributes only the +edge anti-abuse layer — a per-IP rate limit, a body size limit, and a +`GET`/`HEAD`/`POST` method allow-list for the class — and answers +`502 bad_gateway` when the backend is unreachable. + +The `admin` class carries its own budgets, isolated from the other public +classes: + +- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_MAX_BODY_BYTES` (default `65536`); +- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_REQUESTS` (default `120`); +- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_WINDOW` (default `1m`); +- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_BURST` (default `40`). + ### Operational Admin Surface The gateway may expose one private operational HTTP listener used for metrics. diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index 2c1095b..3c30405 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -78,6 +78,15 @@ func run(ctx context.Context) (err error) { AuthService: authServiceAdapter{rest: backend.REST()}, } + adminConsoleProxy, err := restapi.NewBackendConsoleProxy(cfg.Backend.HTTPBaseURL, logger) + if err != nil { + _ = backend.Close() + _ = telemetryRuntime.Shutdown(context.Background()) + _ = logging.Sync(logger) + return fmt.Errorf("build admin console proxy: %w", err) + } + publicRESTDeps.AdminConsoleProxy = adminConsoleProxy + grpcDeps, components, cleanup, err := newAuthenticatedGRPCDependencies(ctx, cfg, logger, telemetryRuntime, backend) if err != nil { _ = backend.Close() diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go index 3d697f6..410acb4 100644 --- a/gateway/internal/config/config.go +++ b/gateway/internal/config/config.go @@ -276,6 +276,22 @@ const ( // configures the public_misc rate-limit burst. publicMiscRateLimitBurstEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_RATE_LIMIT_BURST" + // adminMaxBodyBytesEnvVar names the environment variable that configures + // the maximum accepted request body size for the admin console class. + adminMaxBodyBytesEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_MAX_BODY_BYTES" + + // adminRateLimitRequestsEnvVar names the environment variable that + // configures the admin console request budget per window. + adminRateLimitRequestsEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_REQUESTS" + + // adminRateLimitWindowEnvVar names the environment variable that configures + // the admin console rate-limit window. + adminRateLimitWindowEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_WINDOW" + + // adminRateLimitBurstEnvVar names the environment variable that configures + // the admin console rate-limit burst. + adminRateLimitBurstEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_BURST" + // sendEmailCodeIdentityRateLimitRequestsEnvVar names the environment // variable that configures the send-email-code identity request budget per // window. @@ -372,6 +388,14 @@ const ( defaultPublicMiscRateLimitRequests = 30 defaultPublicMiscRateLimitBurst = 10 + // Admin console class: sized for a human operator clicking through pages + // and submitting forms, while still throttling Basic Auth brute-force at + // the edge. The body budget accommodates form posts. + defaultAdminMaxBodyBytes = int64(65536) + + defaultAdminRateLimitRequests = 120 + defaultAdminRateLimitBurst = 40 + defaultSendEmailCodeIdentityRateLimitRequests = 3 defaultSendEmailCodeIdentityRateLimitBurst = 1 @@ -439,6 +463,11 @@ type PublicHTTPAntiAbuseConfig struct { // PublicMisc applies to the stable public_misc route class. PublicMisc PublicRoutePolicyConfig + // Admin applies to the stable admin route class — the `/_gm` operator + // console reverse-proxied to the backend. Only per-IP limiting applies; + // the class carries no identity buckets. + Admin PublicRoutePolicyConfig + // SendEmailCodeIdentity applies the additional identity limiter for // send-email-code. SendEmailCodeIdentity PublicAuthIdentityPolicyConfig @@ -708,6 +737,14 @@ func DefaultPublicHTTPConfig() PublicHTTPConfig { Burst: defaultPublicMiscRateLimitBurst, }, }, + Admin: PublicRoutePolicyConfig{ + MaxBodyBytes: defaultAdminMaxBodyBytes, + RateLimit: PublicRateLimitConfig{ + Requests: defaultAdminRateLimitRequests, + Window: defaultClassRateLimitWindow, + Burst: defaultAdminRateLimitBurst, + }, + }, SendEmailCodeIdentity: PublicAuthIdentityPolicyConfig{ RateLimit: PublicRateLimitConfig{ Requests: defaultSendEmailCodeIdentityRateLimitRequests, @@ -1092,6 +1129,18 @@ func LoadFromEnv() (Config, error) { } cfg.PublicHTTP.AntiAbuse.PublicMisc = publicMiscPolicy + adminPolicy, err := loadPublicRoutePolicyConfigFromEnv( + cfg.PublicHTTP.AntiAbuse.Admin, + adminMaxBodyBytesEnvVar, + adminRateLimitRequestsEnvVar, + adminRateLimitWindowEnvVar, + adminRateLimitBurstEnvVar, + ) + if err != nil { + return Config{}, err + } + cfg.PublicHTTP.AntiAbuse.Admin = adminPolicy + sendIdentityPolicy, err := loadPublicAuthIdentityPolicyConfigFromEnv( cfg.PublicHTTP.AntiAbuse.SendEmailCodeIdentity, sendEmailCodeIdentityRateLimitRequestsEnvVar, @@ -1247,6 +1296,9 @@ func LoadFromEnv() (Config, error) { if err := validatePublicRoutePolicyConfig(cfg.PublicHTTP.AntiAbuse.PublicMisc, publicMiscMaxBodyBytesEnvVar, publicMiscRateLimitRequestsEnvVar, publicMiscRateLimitWindowEnvVar, publicMiscRateLimitBurstEnvVar); err != nil { return Config{}, err } + if err := validatePublicRoutePolicyConfig(cfg.PublicHTTP.AntiAbuse.Admin, adminMaxBodyBytesEnvVar, adminRateLimitRequestsEnvVar, adminRateLimitWindowEnvVar, adminRateLimitBurstEnvVar); err != nil { + return Config{}, err + } if err := validatePublicAuthIdentityPolicyConfig(cfg.PublicHTTP.AntiAbuse.SendEmailCodeIdentity, sendEmailCodeIdentityRateLimitRequestsEnvVar, sendEmailCodeIdentityRateLimitWindowEnvVar, sendEmailCodeIdentityRateLimitBurstEnvVar); err != nil { return Config{}, err } diff --git a/gateway/internal/restapi/admin_proxy.go b/gateway/internal/restapi/admin_proxy.go new file mode 100644 index 0000000..d9f2855 --- /dev/null +++ b/gateway/internal/restapi/admin_proxy.go @@ -0,0 +1,49 @@ +package restapi + +import ( + "fmt" + "net/http" + "net/http/httputil" + "net/url" + + "go.uber.org/zap" +) + +// NewBackendConsoleProxy builds the reverse proxy that forwards operator +// console traffic (`/_gm` and `/_gm/*`) to the backend at backendBaseURL. +// +// The proxy is intentionally thin: it preserves the inbound request path and +// the inbound Host header — the latter so the backend's same-origin CSRF check +// observes the public host rather than the internal upstream — and relays the +// backend response unchanged, including its 401 Basic Auth challenge. It +// answers 502 when the backend is unreachable. Authentication, rendering, and +// every state change live in the backend; the gateway contributes only the +// public anti-abuse layer that runs ahead of this handler. +func NewBackendConsoleProxy(backendBaseURL string, logger *zap.Logger) (http.Handler, error) { + target, err := url.Parse(backendBaseURL) + if err != nil { + return nil, fmt.Errorf("parse backend base URL %q: %w", backendBaseURL, err) + } + if target.Scheme == "" || target.Host == "" { + return nil, fmt.Errorf("backend base URL %q must be absolute", backendBaseURL) + } + + if logger == nil { + logger = zap.NewNop() + } + logger = logger.Named("admin_console_proxy") + + return &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + pr.SetURL(target) + // SetURL clears Out.Host so the target host is used; restore the + // inbound Host so the backend sees the public origin. + pr.Out.Host = pr.In.Host + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + logger.Warn("admin console upstream error", + zap.String("path", r.URL.Path), zap.Error(err)) + w.WriteHeader(http.StatusBadGateway) + }, + }, nil +} diff --git a/gateway/internal/restapi/admin_proxy_test.go b/gateway/internal/restapi/admin_proxy_test.go new file mode 100644 index 0000000..d2c26ed --- /dev/null +++ b/gateway/internal/restapi/admin_proxy_test.go @@ -0,0 +1,198 @@ +package restapi + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "galaxy/gateway/internal/config" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// proxyRequest builds a test request whose context carries a cancellation +// signal. A real http.Server always supplies one; httptest.NewRequest does not, +// and without it httputil.ReverseProxy falls back to the legacy CloseNotifier +// path, which panics under gin's ResponseWriter wrapping an +// httptest.ResponseRecorder. Cancelling at test cleanup keeps the context live +// for the synchronous ServeHTTP call. +func proxyRequest(t *testing.T, method, target string, body io.Reader) *http.Request { + t.Helper() + req := httptest.NewRequest(method, target, body) + ctx, cancel := context.WithCancel(req.Context()) + t.Cleanup(cancel) + return req.WithContext(ctx) +} + +func TestAdminConsoleProxyForwardsToBackend(t *testing.T) { + var gotPath, gotHost, gotAuth string + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotHost = r.Host + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte("

Dashboard

")) + })) + defer backend.Close() + + proxy, err := NewBackendConsoleProxy(backend.URL, nil) + require.NoError(t, err) + handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{AdminConsoleProxy: proxy}) + + req := proxyRequest(t, http.MethodGet, "http://galaxy.lan/_gm/", nil) + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "Dashboard") + assert.Equal(t, "/_gm/", gotPath) + assert.Equal(t, "galaxy.lan", gotHost, "inbound Host must be preserved for same-origin CSRF checks") + assert.True(t, strings.HasPrefix(gotAuth, "Basic "), "Authorization header must be forwarded to the backend") +} + +func TestAdminConsoleProxyForwardsFormPost(t *testing.T) { + var gotPath, gotBody, gotContentType string + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotContentType = r.Header.Get("Content-Type") + body, _ := io.ReadAll(r.Body) + gotBody = string(body) + w.WriteHeader(http.StatusSeeOther) + })) + defer backend.Close() + + proxy, err := NewBackendConsoleProxy(backend.URL, nil) + require.NoError(t, err) + handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{AdminConsoleProxy: proxy}) + + const form = "_csrf=token&reason=spam" + req := proxyRequest(t, http.MethodPost, "http://galaxy.lan/_gm/users/1/sanctions", strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, "/_gm/users/1/sanctions", gotPath) + assert.Equal(t, form, gotBody, "request body must reach the backend intact through the anti-abuse buffer") + assert.Contains(t, gotContentType, "x-www-form-urlencoded") +} + +func TestAdminConsoleProxyRelaysAuthChallenge(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("WWW-Authenticate", `Basic realm="galaxy-admin"`) + w.WriteHeader(http.StatusUnauthorized) + })) + defer backend.Close() + + proxy, err := NewBackendConsoleProxy(backend.URL, nil) + require.NoError(t, err) + handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{AdminConsoleProxy: proxy}) + + req := proxyRequest(t, http.MethodGet, "http://galaxy.lan/_gm/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Contains(t, rec.Header().Get("WWW-Authenticate"), "Basic") +} + +func TestAdminConsoleProxyRejectsDisallowedMethod(t *testing.T) { + var hits int32 + backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + atomic.AddInt32(&hits, 1) + })) + defer backend.Close() + + proxy, err := NewBackendConsoleProxy(backend.URL, nil) + require.NoError(t, err) + handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{AdminConsoleProxy: proxy}) + + req := proxyRequest(t, http.MethodDelete, "http://galaxy.lan/_gm/users/1", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) + assert.Equal(t, int32(0), atomic.LoadInt32(&hits), "backend must not be reached for a rejected method") +} + +func TestAdminConsoleProxyRejectsOversizedBody(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer backend.Close() + + proxy, err := NewBackendConsoleProxy(backend.URL, nil) + require.NoError(t, err) + cfg := config.DefaultPublicHTTPConfig() + cfg.AntiAbuse.Admin.MaxBodyBytes = 8 + handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AdminConsoleProxy: proxy}) + + req := proxyRequest(t, http.MethodPost, "http://galaxy.lan/_gm/users/1/sanctions", + strings.NewReader("this body is well beyond eight bytes")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code) +} + +func TestAdminConsoleProxyRateLimitsPerIP(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + + proxy, err := NewBackendConsoleProxy(backend.URL, nil) + require.NoError(t, err) + cfg := config.DefaultPublicHTTPConfig() + cfg.AntiAbuse.Admin.RateLimit = config.PublicRateLimitConfig{Requests: 1, Window: time.Minute, Burst: 1} + handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AdminConsoleProxy: proxy}) + + do := func() int { + req := proxyRequest(t, http.MethodGet, "http://galaxy.lan/_gm/", nil) + req.RemoteAddr = "203.0.113.7:5555" + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec.Code + } + + assert.Equal(t, http.StatusOK, do(), "first request within budget") + assert.Equal(t, http.StatusTooManyRequests, do(), "second request exhausts the per-IP admin budget") +} + +func TestAdminConsoleProxyReturns502WhenBackendUnreachable(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + backendURL := backend.URL + backend.Close() // close immediately so the next dial is refused + + proxy, err := NewBackendConsoleProxy(backendURL, nil) + require.NoError(t, err) + handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{AdminConsoleProxy: proxy}) + + req := proxyRequest(t, http.MethodGet, "http://galaxy.lan/_gm/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadGateway, rec.Code) +} + +func TestAdminConsoleNotMountedWhenProxyNil(t *testing.T) { + handler := newPublicHandler(ServerDependencies{}) + + req := proxyRequest(t, http.MethodGet, "http://galaxy.lan/_gm/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestNewBackendConsoleProxyRejectsRelativeURL(t *testing.T) { + _, err := NewBackendConsoleProxy("/not-absolute", nil) + assert.Error(t, err) +} diff --git a/gateway/internal/restapi/public_anti_abuse.go b/gateway/internal/restapi/public_anti_abuse.go index c390fe6..ebbe742 100644 --- a/gateway/internal/restapi/public_anti_abuse.go +++ b/gateway/internal/restapi/public_anti_abuse.go @@ -234,6 +234,8 @@ func publicRoutePolicyForClass(policy config.PublicHTTPAntiAbuseConfig, class Pu return policy.BrowserBootstrap case PublicRouteClassBrowserAsset: return policy.BrowserAsset + case PublicRouteClassAdmin: + return policy.Admin default: return policy.PublicMisc } @@ -252,6 +254,8 @@ func publicAuthIdentityPolicyForPath(requestPath string, policy config.PublicHTT func allowedMethodsForRequestShape(r *http.Request) []string { switch { + case isAdminConsolePath(r.URL.Path): + return []string{http.MethodGet, http.MethodHead, http.MethodPost} case isPublicAuthPath(r.URL.Path): return []string{http.MethodPost} case isProbePath(r.URL.Path): @@ -284,6 +288,17 @@ func isPublicAuthPath(requestPath string) bool { } } +// isAdminConsoleRequest reports whether r targets the operator console surface. +func isAdminConsoleRequest(r *http.Request) bool { + return isAdminConsolePath(r.URL.Path) +} + +// isAdminConsolePath reports whether requestPath is the admin console root +// (`/_gm`) or any path beneath it (`/_gm/...`). +func isAdminConsolePath(requestPath string) bool { + return requestPath == "/_gm" || strings.HasPrefix(requestPath, "/_gm/") +} + func isProbePath(requestPath string) bool { switch requestPath { case "/healthz", "/readyz": diff --git a/gateway/internal/restapi/server.go b/gateway/internal/restapi/server.go index 6a9c788..839407e 100644 --- a/gateway/internal/restapi/server.go +++ b/gateway/internal/restapi/server.go @@ -48,6 +48,10 @@ const ( // PublicRouteClassPublicMisc identifies public traffic that does not match a // more specific class. PublicRouteClassPublicMisc PublicRouteClass = "public_misc" + + // PublicRouteClassAdmin identifies operator console traffic reverse-proxied + // to the backend under the `/_gm` prefix. + PublicRouteClassAdmin PublicRouteClass = "admin" ) var configureGinModeOnce sync.Once @@ -60,6 +64,7 @@ func (c PublicRouteClass) Normalized() PublicRouteClass { case PublicRouteClassPublicAuth, PublicRouteClassBrowserBootstrap, PublicRouteClassBrowserAsset, + PublicRouteClassAdmin, PublicRouteClassPublicMisc: return c default: @@ -110,6 +115,14 @@ type ServerDependencies struct { // Telemetry records low-cardinality edge metrics. When nil, metrics are // disabled. Telemetry *telemetry.Runtime + + // AdminConsoleProxy, when non-nil, handles `/_gm` and `/_gm/*` by + // reverse-proxying to the backend operator console after the public + // anti-abuse layer (per-IP rate limit, body, and method checks for the + // admin route class) has run. Authentication is delegated to the + // backend's admin Basic Auth, whose 401 challenge passes straight back + // to the browser. When nil, the admin console surface is not mounted. + AdminConsoleProxy http.Handler } // Server owns the public unauthenticated REST listener exposed by the gateway. @@ -229,6 +242,8 @@ type defaultPublicTrafficClassifier struct{} // later drive anti-abuse policy and rate limiting. func (defaultPublicTrafficClassifier) Classify(r *http.Request) PublicRouteClass { switch { + case isAdminConsoleRequest(r): + return PublicRouteClassAdmin case isPublicAuthRequest(r): return PublicRouteClassPublicAuth case isBrowserBootstrapRequest(r): @@ -290,6 +305,12 @@ func newPublicHandlerWithConfig(cfg config.PublicHTTPConfig, deps ServerDependen router.POST("/api/v1/public/auth/send-email-code", handleSendEmailCode(deps.AuthService, cfg.AuthUpstreamTimeout)) router.POST("/api/v1/public/auth/confirm-email-code", handleConfirmEmailCode(deps.AuthService, cfg.AuthUpstreamTimeout)) + if deps.AdminConsoleProxy != nil { + adminConsole := gin.WrapH(deps.AdminConsoleProxy) + router.Any("/_gm", adminConsole) + router.Any("/_gm/*proxyPath", adminConsole) + } + router.NoMethod(func(c *gin.Context) { allowMethods := allowedMethodsForPath(c.Request.URL.Path) if allowMethods != "" { diff --git a/gateway/internal/restapi/server_test.go b/gateway/internal/restapi/server_test.go index e21fd3f..0774cb1 100644 --- a/gateway/internal/restapi/server_test.go +++ b/gateway/internal/restapi/server_test.go @@ -169,6 +169,31 @@ func TestDefaultPublicTrafficClassifier(t *testing.T) { accept: "text/html", wantClass: PublicRouteClassBrowserBootstrap, }, + { + name: "admin console root", + method: http.MethodGet, + target: "/_gm", + wantClass: PublicRouteClassAdmin, + }, + { + name: "admin console page wins over browser accept header", + method: http.MethodGet, + target: "/_gm/users", + accept: "text/html", + wantClass: PublicRouteClassAdmin, + }, + { + name: "admin console asset wins over browser asset shape", + method: http.MethodGet, + target: "/_gm/assets/console.css", + wantClass: PublicRouteClassAdmin, + }, + { + name: "admin console form post", + method: http.MethodPost, + target: "/_gm/users/123/sanctions", + wantClass: PublicRouteClassAdmin, + }, } for _, tt := range tests { @@ -215,6 +240,11 @@ func TestPublicRouteClassNormalized(t *testing.T) { input: PublicRouteClassPublicMisc, want: PublicRouteClassPublicMisc, }, + { + name: "admin", + input: PublicRouteClassAdmin, + want: PublicRouteClassAdmin, + }, { name: "unknown collapses to misc", input: PublicRouteClass("unexpected"), diff --git a/tools/dev-deploy/Caddyfile.dev b/tools/dev-deploy/Caddyfile.dev index b1acfb5..af25751 100644 --- a/tools/dev-deploy/Caddyfile.dev +++ b/tools/dev-deploy/Caddyfile.dev @@ -29,6 +29,14 @@ reverse_proxy galaxy-api:8080 } + # Operator console. Shares the gateway public listener with `/api`; the + # gateway applies the admin anti-abuse class and reverse-proxies to the + # backend `/_gm` surface, which enforces Basic Auth and renders the pages. + @gm path /_gm /_gm/* + handle @gm { + reverse_proxy galaxy-api:8080 + } + # Bare `/game` (no trailing slash) -> `/game/` so the SPA root # resolves before the site catch-all can claim it. handle /game { diff --git a/tools/dev-deploy/docker-compose.yml b/tools/dev-deploy/docker-compose.yml index 2813833..23e260e 100644 --- a/tools/dev-deploy/docker-compose.yml +++ b/tools/dev-deploy/docker-compose.yml @@ -109,7 +109,18 @@ services: BACKEND_MAIL_WORKER_INTERVAL: 500ms BACKEND_NOTIFICATION_WORKER_INTERVAL: 500ms BACKEND_OTEL_TRACES_EXPORTER: none - BACKEND_OTEL_METRICS_EXPORTER: none + # Prometheus metrics are enabled in dev so the `/metrics` scrape + # endpoint is live and stable ahead of standing up a Prometheus + + # Grafana stack on the internal network. The listener stays internal + # (not mapped to the host); nothing scrapes it yet. + BACKEND_OTEL_METRICS_EXPORTER: prometheus + BACKEND_OTEL_PROMETHEUS_LISTEN_ADDR: ":9100" + # Operator console (`/_gm`): Basic Auth bootstrap account plus the + # stateless CSRF key. Dev-only non-secrets, overridable via `.env`; a + # stable CSRF key keeps console forms valid across redeploys. + BACKEND_ADMIN_BOOTSTRAP_USER: ${BACKEND_ADMIN_BOOTSTRAP_USER:-gm} + BACKEND_ADMIN_BOOTSTRAP_PASSWORD: ${BACKEND_ADMIN_BOOTSTRAP_PASSWORD:-gm-dev-password} + BACKEND_ADMIN_CONSOLE_CSRF_KEY: ${BACKEND_ADMIN_CONSOLE_CSRF_KEY:-dev-admin-console-csrf-key} # Long-lived dev environment always opts into the fixed-code # override so a returning developer can sign in with `123456` # even after the matching browser session was cleared (the real @@ -180,6 +191,10 @@ services: GATEWAY_LOG_LEVEL: info GATEWAY_PUBLIC_HTTP_ADDR: ":8080" GATEWAY_AUTHENTICATED_GRPC_ADDR: ":9090" + # Private admin listener exposes the Prometheus `/metrics` endpoint on + # the internal network — live and stable for a future scrape, not + # mapped to the host. + GATEWAY_ADMIN_HTTP_ADDR: ":9191" GATEWAY_BACKEND_HTTP_URL: "http://galaxy-backend:8080" GATEWAY_BACKEND_GRPC_PUSH_URL: "galaxy-backend:8081" GATEWAY_BACKEND_GATEWAY_CLIENT_ID: dev-gateway-1 @@ -208,6 +223,9 @@ services: GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_RATE_LIMIT_BURST: "1000" GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_BOOTSTRAP_MAX_BODY_BYTES: "65536" GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_ASSET_MAX_BODY_BYTES: "65536" + GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_MAX_BODY_BYTES: "131072" + GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_REQUESTS: "10000" + GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_BURST: "1000" GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_REQUESTS: "10000" GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_BURST: "1000" GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_SESSION_RATE_LIMIT_REQUESTS: "10000" From 985e51d25ea4ffa42c55cf9f642f765222ff895e Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 31 May 2026 20:04:48 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat(admin-console):=20Stage=202=20?= =?UTF-8?q?=E2=80=94=20dashboard=20monitoring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the console landing page into an operational dashboard. - new internal/opsstatus: read-only Postgres projection via go-jet — ping + per-status COUNT/GROUP BY on runtime_records, mail_deliveries, notification_routes, and a malformed-intent count; degrades per-probe into Snapshot.Errors rather than failing the page - dashboard renders backend readiness, database health, the three status tables, the malformed count, and any collection errors; falls back to a "monitoring not wired" note when no reader is injected - AdminConsoleHandlers now takes an AdminConsoleDeps struct (Monitor + Ready added) so later stages add service refs without churning the signature Tests: opsstatus store test against a Postgres testcontainer (empty schema + one enqueued delivery); dashboard render tests with a fake reader (with and without monitoring). Docs: ARCHITECTURE 14.1 + FUNCTIONAL 10.2.1 (+ru) describe the dashboard. (Prometheus /metrics exporters were already enabled in dev-deploy in Stage 1.) --- backend/cmd/backend/main.go | 16 +- .../internal/adminconsole/assets/console.css | 22 +++ backend/internal/adminconsole/dashboard.go | 24 +++ .../templates/pages/dashboard.gohtml | 47 ++++++ backend/internal/opsstatus/opsstatus.go | 139 ++++++++++++++++ backend/internal/opsstatus/opsstatus_test.go | 155 ++++++++++++++++++ .../internal/server/handlers_admin_console.go | 58 ++++++- .../server/handlers_admin_console_test.go | 77 ++++++++- docs/ARCHITECTURE.md | 8 +- docs/FUNCTIONAL.md | 6 + docs/FUNCTIONAL_ru.md | 6 + 11 files changed, 544 insertions(+), 14 deletions(-) create mode 100644 backend/internal/adminconsole/dashboard.go create mode 100644 backend/internal/opsstatus/opsstatus.go create mode 100644 backend/internal/opsstatus/opsstatus_test.go diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 25acf9c..a042347 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -38,6 +38,7 @@ import ( "galaxy/backend/internal/mail" "galaxy/backend/internal/metricsapi" "galaxy/backend/internal/notification" + "galaxy/backend/internal/opsstatus" backendpostgres "galaxy/backend/internal/postgres" "galaxy/backend/push" "galaxy/backend/internal/runtime" @@ -357,6 +358,10 @@ func run(ctx context.Context) (err error) { userGamesHandlers := backendserver.NewUserGamesHandlers(runtimeSvc, engineCli, logger) userMailHandlers := backendserver.NewUserMailHandlers(diplomailSvc, lobbySvc, userSvc, logger) + ready := func() bool { + return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready() + } + var consoleCSRF *adminconsole.CSRF if cfg.AdminConsole.CSRFKey != "" { consoleCSRF = adminconsole.NewCSRF([]byte(cfg.AdminConsole.CSRFKey)) @@ -368,11 +373,12 @@ func run(ctx context.Context) (err error) { logger.Warn("admin console CSRF key not set; using a per-process random key (forms reset on restart, not valid across replicas)", zap.String("env", "BACKEND_ADMIN_CONSOLE_CSRF_KEY")) } - adminConsoleHandlers := backendserver.NewAdminConsoleHandlers(adminconsole.MustNewRenderer(), consoleCSRF, logger) - - ready := func() bool { - return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready() - } + adminConsoleHandlers := backendserver.NewAdminConsoleHandlers(backendserver.AdminConsoleDeps{ + CSRF: consoleCSRF, + Monitor: opsstatus.NewStore(db), + Ready: ready, + Logger: logger, + }) handler, err := backendserver.NewRouter(backendserver.RouterDependencies{ Logger: logger, diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css index bc29129..1fcec80 100644 --- a/backend/internal/adminconsole/assets/console.css +++ b/backend/internal/adminconsole/assets/console.css @@ -47,3 +47,25 @@ h1 { font-size: 1.4rem; margin: 0 0 0.4rem; } .card:hover { background: var(--panel-hi); text-decoration: none; } .card h2 { font-size: 1.05rem; margin: 0 0 0.3rem; color: var(--accent); } .card p { margin: 0; color: var(--ink-dim); font-size: 0.9rem; } + +.panel { + padding: 0.9rem 1.1rem; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + margin-bottom: 1rem; +} +.panel h2 { font-size: 1rem; margin: 0 0 0.6rem; color: var(--ink); } +.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 1rem; } +.grid .panel { margin-bottom: 0; } +.kv { list-style: none; margin: 0; padding: 0; } +.kv li { padding: 0.15rem 0; color: var(--ink-dim); } +.counts { width: 100%; border-collapse: collapse; font-size: 0.9rem; } +.counts td { padding: 0.2rem 0; border-bottom: 1px solid var(--line); color: var(--ink-dim); } +.counts td.num { text-align: right; color: var(--ink); font-variant-numeric: tabular-nums; } +.bignum { font-size: 1.6rem; margin: 0; color: var(--ink); } +.note { color: var(--ink-dim); font-style: italic; margin: 0.2rem 0; } +.errors { border-color: var(--danger); } +.errors ul { margin: 0; padding-left: 1.1rem; color: var(--danger); } +.ok { color: var(--ok); } +.bad { color: var(--danger); } diff --git a/backend/internal/adminconsole/dashboard.go b/backend/internal/adminconsole/dashboard.go new file mode 100644 index 0000000..ed39ea3 --- /dev/null +++ b/backend/internal/adminconsole/dashboard.go @@ -0,0 +1,24 @@ +package adminconsole + +// StatusCount pairs a status label with its current row count for the +// dashboard's per-status tables. It is the view-layer counterpart of the +// data gathered by the ops-status reader; the server handler maps between +// them so this package stays free of database concerns. +type StatusCount struct { + Status string + Count int64 +} + +// DashboardData is the view model for the console landing page. MonitorAvailable +// is false when no ops-status reader is wired, in which case the monitoring +// panels are omitted. Errors carries non-fatal probe failures for display. +type DashboardData struct { + MonitorAvailable bool + BackendReady bool + PostgresHealthy bool + Runtimes []StatusCount + MailDeliveries []StatusCount + NotificationRoutes []StatusCount + NotificationMalformed int64 + Errors []string +} diff --git a/backend/internal/adminconsole/templates/pages/dashboard.gohtml b/backend/internal/adminconsole/templates/pages/dashboard.gohtml index cef9d9f..721323f 100644 --- a/backend/internal/adminconsole/templates/pages/dashboard.gohtml +++ b/backend/internal/adminconsole/templates/pages/dashboard.gohtml @@ -1,6 +1,43 @@ {{define "content" -}}

Dashboard

Signed in as {{.Username}}.

+{{with .Data}} +
+

Health

+
    +
  • Backend ready: {{if .BackendReady}}yes{{else}}no{{end}}
  • +
  • Postgres: {{if .PostgresHealthy}}healthy{{else}}unreachable{{end}}
  • +
+
+{{if .MonitorAvailable}} +
+
+

Game runtimes

+ {{template "statuscounts" .Runtimes}} +
+
+

Mail deliveries

+ {{template "statuscounts" .MailDeliveries}} +
+
+

Notification routes

+ {{template "statuscounts" .NotificationRoutes}} +
+
+

Malformed notifications

+

{{.NotificationMalformed}}

+
+
+{{if .Errors}} +
+

Collection errors

+
    {{range .Errors}}
  • {{.}}
  • {{end}}
+
+{{end}} +{{else}} +

Monitoring is not wired in this deployment.

+{{end}} +{{end}}

Users

@@ -20,3 +57,13 @@
{{- end}} + +{{define "statuscounts" -}} +{{if .}} + +{{range .}}{{end}} +
{{.Status}}{{.Count}}
+{{else}} +

none

+{{end}} +{{- end}} diff --git a/backend/internal/opsstatus/opsstatus.go b/backend/internal/opsstatus/opsstatus.go new file mode 100644 index 0000000..03ed330 --- /dev/null +++ b/backend/internal/opsstatus/opsstatus.go @@ -0,0 +1,139 @@ +// Package opsstatus reads point-in-time operational signals from Postgres for +// the admin console dashboard: database reachability, per-status counts of game +// runtimes, mail deliveries, and notification routes, plus the malformed +// notification-intent count. +// +// It is a read-only projection built entirely through the go-jet query builder +// against the generated table bindings; it owns no business logic and mutates +// nothing. Richer, historical metrics are out of scope — those belong to the +// Prometheus exporters wired on `backend` and `gateway`. +package opsstatus + +import ( + "context" + "database/sql" + "fmt" + "time" + + "galaxy/backend/internal/postgres/jet/backend/table" + + "github.com/go-jet/jet/v2/postgres" +) + +// defaultCollectTimeout bounds a single Collect call so a slow or wedged +// database cannot hang the dashboard request. +const defaultCollectTimeout = 3 * time.Second + +// StatusCount pairs a status value with the number of rows currently in it. +type StatusCount struct { + Status string + Count int64 +} + +// Snapshot is a point-in-time view of the operational signals rendered on the +// dashboard. Errors collects per-query failures so a single failing probe +// degrades to a visible note rather than failing the whole page. +type Snapshot struct { + PostgresHealthy bool + Runtimes []StatusCount + MailDeliveries []StatusCount + NotificationRoutes []StatusCount + NotificationMalformed int64 + Errors []string +} + +// Reader collects an operational Snapshot. The admin console depends on this +// interface so the dashboard can be tested without a database. +type Reader interface { + Collect(ctx context.Context) Snapshot +} + +// Store is the Postgres-backed Reader. +type Store struct { + db *sql.DB + timeout time.Duration +} + +// NewStore constructs a Store reading from db. +func NewStore(db *sql.DB) *Store { + return &Store{db: db, timeout: defaultCollectTimeout} +} + +// Collect gathers the dashboard signals within a bounded timeout. It never +// returns an error: a failed probe is recorded in Snapshot.Errors and the +// remaining probes still run, except that a failed Postgres ping short-circuits +// the rest (the dependent queries would only fail the same way). +func (s *Store) Collect(ctx context.Context) Snapshot { + ctx, cancel := context.WithTimeout(ctx, s.timeout) + defer cancel() + + var snap Snapshot + + if err := s.db.PingContext(ctx); err != nil { + snap.Errors = append(snap.Errors, fmt.Sprintf("postgres ping: %v", err)) + return snap + } + snap.PostgresHealthy = true + + if counts, err := s.statusCounts(ctx, table.RuntimeRecords.Status, table.RuntimeRecords); err != nil { + snap.Errors = append(snap.Errors, fmt.Sprintf("runtime status counts: %v", err)) + } else { + snap.Runtimes = counts + } + + if counts, err := s.statusCounts(ctx, table.MailDeliveries.Status, table.MailDeliveries); err != nil { + snap.Errors = append(snap.Errors, fmt.Sprintf("mail delivery counts: %v", err)) + } else { + snap.MailDeliveries = counts + } + + if counts, err := s.statusCounts(ctx, table.NotificationRoutes.Status, table.NotificationRoutes); err != nil { + snap.Errors = append(snap.Errors, fmt.Sprintf("notification route counts: %v", err)) + } else { + snap.NotificationRoutes = counts + } + + if n, err := s.countAll(ctx, table.NotificationMalformedIntents); err != nil { + snap.Errors = append(snap.Errors, fmt.Sprintf("malformed notification count: %v", err)) + } else { + snap.NotificationMalformed = n + } + + return snap +} + +// statusCounts runs `SELECT status, COUNT(*) FROM GROUP BY status` +// through jet and returns the rows ordered by status. +func (s *Store) statusCounts(ctx context.Context, status postgres.ColumnString, from postgres.ReadableTable) ([]StatusCount, error) { + stmt := postgres.SELECT( + status.AS("status_count.status"), + postgres.COUNT(postgres.STAR).AS("status_count.count"), + ).FROM(from).GROUP_BY(status).ORDER_BY(status.ASC()) + + var rows []struct { + Status string `alias:"status_count.status"` + Count int64 `alias:"status_count.count"` + } + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + return nil, err + } + + out := make([]StatusCount, len(rows)) + for i, row := range rows { + out[i] = StatusCount{Status: row.Status, Count: row.Count} + } + return out, nil +} + +// countAll runs `SELECT COUNT(*) FROM ` through jet. +func (s *Store) countAll(ctx context.Context, from postgres.ReadableTable) (int64, error) { + stmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).FROM(from) + + var row struct { + Count int64 `alias:"count"` + } + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + return 0, err + } + return row.Count, nil +} diff --git a/backend/internal/opsstatus/opsstatus_test.go b/backend/internal/opsstatus/opsstatus_test.go new file mode 100644 index 0000000..b2edf27 --- /dev/null +++ b/backend/internal/opsstatus/opsstatus_test.go @@ -0,0 +1,155 @@ +package opsstatus_test + +import ( + "context" + "database/sql" + "net/url" + "testing" + "time" + + "galaxy/backend/internal/mail" + "galaxy/backend/internal/opsstatus" + backendpg "galaxy/backend/internal/postgres" + pgshared "galaxy/postgres" + + "github.com/google/uuid" + testcontainers "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + pgImage = "postgres:16-alpine" + pgUser = "galaxy" + pgPassword = "galaxy" + pgDatabase = "galaxy_backend" + pgSchema = "backend" + pgStartup = 90 * time.Second + pgOpTO = 10 * time.Second +) + +// startPostgres mirrors the per-package scaffolding used by the other store +// tests: spin up Postgres, apply migrations, return *sql.DB. +func startPostgres(t *testing.T) *sql.DB { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + t.Cleanup(cancel) + + pgContainer, err := tcpostgres.Run(ctx, pgImage, + tcpostgres.WithDatabase(pgDatabase), + tcpostgres.WithUsername(pgUser), + tcpostgres.WithPassword(pgPassword), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(pgStartup), + ), + ) + if err != nil { + t.Skipf("postgres testcontainer unavailable, skipping: %v", err) + } + t.Cleanup(func() { + if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil { + t.Errorf("terminate postgres container: %v", termErr) + } + }) + + baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("connection string: %v", err) + } + scopedDSN, err := dsnWithSearchPath(baseDSN, pgSchema) + if err != nil { + t.Fatalf("scope dsn: %v", err) + } + + cfg := pgshared.DefaultConfig() + cfg.PrimaryDSN = scopedDSN + cfg.OperationTimeout = pgOpTO + + db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...) + if err != nil { + t.Fatalf("open primary: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil { + t.Fatalf("ping: %v", err) + } + if err := backendpg.ApplyMigrations(ctx, db); err != nil { + t.Fatalf("apply migrations: %v", err) + } + return db +} + +func dsnWithSearchPath(baseDSN, schema string) (string, error) { + parsed, err := url.Parse(baseDSN) + if err != nil { + return "", err + } + values := parsed.Query() + values.Set("search_path", schema) + if values.Get("sslmode") == "" { + values.Set("sslmode", "disable") + } + parsed.RawQuery = values.Encode() + return parsed.String(), nil +} + +func TestStoreCollect(t *testing.T) { + db := startPostgres(t) + store := opsstatus.NewStore(db) + ctx := context.Background() + + // Empty schema: queries must execute cleanly with zero counts. + empty := store.Collect(ctx) + if !empty.PostgresHealthy { + t.Fatal("PostgresHealthy must be true against a reachable database") + } + if len(empty.Errors) != 0 { + t.Fatalf("unexpected collection errors: %v", empty.Errors) + } + if got := totalCount(empty.MailDeliveries); got != 0 { + t.Fatalf("mail deliveries total = %d, want 0", got) + } + if len(empty.Runtimes) != 0 || len(empty.NotificationRoutes) != 0 { + t.Fatalf("expected empty status slices, got runtimes=%v routes=%v", empty.Runtimes, empty.NotificationRoutes) + } + if empty.NotificationMalformed != 0 { + t.Fatalf("malformed notifications = %d, want 0", empty.NotificationMalformed) + } + + // Enqueue one mail delivery and confirm the GROUP BY count reflects it. + mailStore := mail.NewStore(db) + inserted, err := mailStore.InsertEnqueue(ctx, mail.EnqueueArgs{ + DeliveryID: uuid.New(), + TemplateID: mail.TemplateLoginCode, + IdempotencyKey: uuid.NewString(), + Recipients: []string{"ops@example.test"}, + ContentType: "text/plain", + Subject: "hello", + Body: []byte("hi"), + }) + if err != nil { + t.Fatalf("insert mail delivery: %v", err) + } + if !inserted { + t.Fatal("expected the delivery to be inserted") + } + + after := store.Collect(ctx) + if len(after.Errors) != 0 { + t.Fatalf("unexpected collection errors after insert: %v", after.Errors) + } + if got := totalCount(after.MailDeliveries); got != 1 { + t.Fatalf("mail deliveries total after insert = %d, want 1 (statuses: %v)", got, after.MailDeliveries) + } +} + +func totalCount(counts []opsstatus.StatusCount) int64 { + var total int64 + for _, c := range counts { + total += c.Count + } + return total +} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index fd9b6b5..1846eb8 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -7,6 +7,7 @@ import ( "strings" "galaxy/backend/internal/adminconsole" + "galaxy/backend/internal/opsstatus" "galaxy/backend/internal/server/httperr" "galaxy/backend/internal/server/middleware/basicauth" @@ -25,22 +26,38 @@ type AdminConsoleHandlers struct { renderer *adminconsole.Renderer csrf *adminconsole.CSRF assets http.Handler + monitor opsstatus.Reader + ready func() bool logger *zap.Logger } -// NewAdminConsoleHandlers constructs the console handler set. A nil renderer -// falls back to the embedded default templates; a nil csrf falls back to a -// fresh per-process random key; a nil logger falls back to zap.NewNop. It +// AdminConsoleDeps bundles the collaborators for the operator console. Every +// field is optional: a nil Renderer or CSRF falls back to the embedded default +// templates and a per-process random key; a nil Monitor renders the dashboard +// without the monitoring panels; a nil Ready reports backend readiness as not +// ready; a nil Logger falls back to zap.NewNop. +type AdminConsoleDeps struct { + Renderer *adminconsole.Renderer + CSRF *adminconsole.CSRF + Monitor opsstatus.Reader + Ready func() bool + Logger *zap.Logger +} + +// NewAdminConsoleHandlers constructs the console handler set from deps. It // panics only on conditions that are unrecoverable at startup (template parse // failure or crypto/rand failure), both of which indicate a broken build or // host rather than a runtime input. -func NewAdminConsoleHandlers(renderer *adminconsole.Renderer, csrf *adminconsole.CSRF, logger *zap.Logger) *AdminConsoleHandlers { +func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers { + logger := deps.Logger if logger == nil { logger = zap.NewNop() } + renderer := deps.Renderer if renderer == nil { renderer = adminconsole.MustNewRenderer() } + csrf := deps.CSRF if csrf == nil { generated, err := adminconsole.NewRandomCSRF() if err != nil { @@ -58,17 +75,46 @@ func NewAdminConsoleHandlers(renderer *adminconsole.Renderer, csrf *adminconsole renderer: renderer, csrf: csrf, assets: http.StripPrefix("/_gm/assets/", http.FileServer(http.FS(assetsFS))), + monitor: deps.Monitor, + ready: deps.Ready, logger: logger.Named("http.admin.console"), } } -// Dashboard renders the console landing page (GET /_gm and GET /_gm/). +// Dashboard renders the console landing page (GET /_gm and GET /_gm/), +// including the monitoring panels when an ops-status reader is wired. func (h *AdminConsoleHandlers) Dashboard() gin.HandlerFunc { return func(c *gin.Context) { - h.render(c, http.StatusOK, "dashboard", "dashboard", "Dashboard", nil) + data := adminconsole.DashboardData{} + if h.ready != nil { + data.BackendReady = h.ready() + } + if h.monitor != nil { + data.MonitorAvailable = true + snapshot := h.monitor.Collect(c.Request.Context()) + data.PostgresHealthy = snapshot.PostgresHealthy + data.Runtimes = toViewCounts(snapshot.Runtimes) + data.MailDeliveries = toViewCounts(snapshot.MailDeliveries) + data.NotificationRoutes = toViewCounts(snapshot.NotificationRoutes) + data.NotificationMalformed = snapshot.NotificationMalformed + data.Errors = snapshot.Errors + } + h.render(c, http.StatusOK, "dashboard", "dashboard", "Dashboard", data) } } +// toViewCounts maps ops-status counts to the console's view-layer counts. +func toViewCounts(in []opsstatus.StatusCount) []adminconsole.StatusCount { + if len(in) == 0 { + return nil + } + out := make([]adminconsole.StatusCount, len(in)) + for i, sc := range in { + out[i] = adminconsole.StatusCount{Status: sc.Status, Count: sc.Count} + } + return out +} + // Asset serves the embedded console static assets under `/_gm/assets/`. func (h *AdminConsoleHandlers) Asset() gin.HandlerFunc { return gin.WrapH(h.assets) diff --git a/backend/internal/server/handlers_admin_console_test.go b/backend/internal/server/handlers_admin_console_test.go index 1c99fc1..0bf8926 100644 --- a/backend/internal/server/handlers_admin_console_test.go +++ b/backend/internal/server/handlers_admin_console_test.go @@ -1,6 +1,7 @@ package server import ( + "context" "io" "net/http" "net/http/httptest" @@ -8,18 +9,28 @@ import ( "testing" "galaxy/backend/internal/adminconsole" + "galaxy/backend/internal/opsstatus" "galaxy/backend/internal/server/middleware/basicauth" "github.com/gin-gonic/gin" "go.uber.org/zap" ) +// fakeMonitor is a static opsstatus.Reader for dashboard rendering tests. +type fakeMonitor struct { + snapshot opsstatus.Snapshot +} + +func (f fakeMonitor) Collect(context.Context) opsstatus.Snapshot { + return f.snapshot +} + func newConsoleTestRouter(t *testing.T) http.Handler { t.Helper() handler, err := NewRouter(RouterDependencies{ Logger: zap.NewNop(), AdminVerifier: basicauth.NewStaticVerifier("secret"), - AdminConsole: NewAdminConsoleHandlers(nil, adminconsole.NewCSRF([]byte("test-key")), nil), + AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: adminconsole.NewCSRF([]byte("test-key"))}), }) if err != nil { t.Fatalf("NewRouter: %v", err) @@ -67,6 +78,68 @@ func TestAdminConsoleDashboardRenders(t *testing.T) { } } +func TestAdminConsoleDashboardShowsMonitoring(t *testing.T) { + monitor := fakeMonitor{snapshot: opsstatus.Snapshot{ + PostgresHealthy: true, + Runtimes: []opsstatus.StatusCount{{Status: "running", Count: 3}, {Status: "stopped", Count: 1}}, + MailDeliveries: []opsstatus.StatusCount{{Status: "pending", Count: 2}}, + NotificationRoutes: []opsstatus.StatusCount{{Status: "published", Count: 9}}, + NotificationMalformed: 4, + Errors: []string{"notification route counts: boom"}, + }} + + handler, err := NewRouter(RouterDependencies{ + Logger: zap.NewNop(), + AdminVerifier: basicauth.NewStaticVerifier("secret"), + AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{ + CSRF: adminconsole.NewCSRF([]byte("test-key")), + Monitor: monitor, + Ready: func() bool { return true }, + }), + }) + if err != nil { + t.Fatalf("NewRouter: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/_gm/", nil) + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, want := range []string{ + "Game runtimes", "running", "stopped", + "Mail deliveries", "pending", + "Notification routes", "published", + "Malformed notifications", + "notification route counts: boom", + "healthy", + } { + if !strings.Contains(body, want) { + t.Errorf("dashboard body missing %q", want) + } + } +} + +func TestAdminConsoleDashboardWithoutMonitor(t *testing.T) { + router := newConsoleTestRouter(t) // no monitor wired + + req := httptest.NewRequest(http.MethodGet, "/_gm/", nil) + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Monitoring is not wired") { + t.Error("dashboard without a monitor should note that monitoring is unavailable") + } +} + func TestAdminConsoleServesAsset(t *testing.T) { router := newConsoleTestRouter(t) @@ -87,7 +160,7 @@ func TestAdminConsoleRequireCSRF(t *testing.T) { gin.SetMode(gin.TestMode) csrf := adminconsole.NewCSRF([]byte("test-key")) - console := NewAdminConsoleHandlers(nil, csrf, nil) + console := NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf}) engine := gin.New() engine.Use(func(c *gin.Context) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c5096d1..a771037 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -602,7 +602,13 @@ only the edge anti-abuse layer. State-changing requests are guarded against CSRF by a stateless token (HMAC-SHA256 over the authenticated username, keyed by `BACKEND_ADMIN_CONSOLE_CSRF_KEY`; a per-process random key is used when the -variable is unset) plus a same-origin `Origin`/`Referer` check. See +variable is unset) plus a same-origin `Origin`/`Referer` check. + +The console landing page is a dashboard that surfaces backend-visible +operational signals — database reachability, per-status game-runtime counts, +and mail/notification queue depths — read directly through the persistence +layer; richer historical metrics come from the Prometheus exporters on +`backend` and `gateway` (see [§17](#17-observability)). See `backend/docs/admin-console.md` for the console design. ## 15. Transport Security Model (gateway boundary) diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 5ec8a75..d9b22cf 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -1178,6 +1178,12 @@ limiting and request limits as the public API, and it carries an anti-CSRF token on every change. The JSON admin API stays internal to the deployment. +The console landing page is a dashboard that summarises operational +health: whether the backend is ready and the database reachable, how many +game runtimes sit in each state, and the depth of the mail and +notification queues. It is a read-only point-in-time view for quick +triage, not a metrics history. + ### 10.3 Admin account management Existing admins can list other admins, create new ones, look up a diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 93c2490..a81f6d7 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -1214,6 +1214,12 @@ admin-API, либо через серверно-рендеримую веб-ко анти-CSRF-токен на каждом изменении. JSON admin-API остаётся внутренним для деплоя. +Стартовая страница консоли — дашборд, сводящий операционное +здоровье: готов ли backend и доступна ли БД, сколько игровых рантаймов +в каждом состоянии, какова глубина очередей почты и уведомлений. Это +read-only-срез на текущий момент для быстрой диагностики, не история +метрик. + ### 10.3 Управление admin-аккаунтами Существующие админы могут перечислять других админов, создавать From cf34710b4f589a186c929f1c338c01ce6cec61b0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 31 May 2026 20:15:19 +0200 Subject: [PATCH 3/6] =?UTF-8?q?feat(admin-console):=20Stage=203=20?= =?UTF-8?q?=E2=80=94=20users=20domain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the operator console's user-administration pages over the existing *user.Service (no new business logic). - GET /_gm/users paginated account list - GET /_gm/users/{id} account detail: profile, entitlement, sanctions - POST /_gm/users/{id}/block apply permanent_block (reason required) - POST /_gm/users/{id}/entitlement set the entitlement tier - POST /_gm/users/{id}/soft-delete soft-delete the account (cascades) The console depends on a UserAdmin interface (satisfied by *user.Service) so the pages render in tests without a database. All writes flow through the CSRF guard, carry the operator as the audit actor, and answer with a 303 redirect; a generic message page handles not-found, validation, and failure notices. Unblock is intentionally absent — the admin API exposes no remove-sanction endpoint. Tests: list/detail render, not-found, block (with actor/scope/reason assertions), missing-reason 400, bad-CSRF 403, entitlement, soft-delete redirect, and the service-unavailable path. Docs: backend/docs/admin-console.md gains the page inventory. --- backend/cmd/backend/main.go | 1 + backend/docs/admin-console.md | 16 + .../internal/adminconsole/assets/console.css | 29 ++ backend/internal/adminconsole/message.go | 11 + .../templates/pages/message.gohtml | 7 + .../templates/pages/user_detail.gohtml | 68 +++++ .../adminconsole/templates/pages/users.gohtml | 27 ++ backend/internal/adminconsole/users.go | 61 ++++ .../internal/server/handlers_admin_console.go | 14 + .../server/handlers_admin_console_users.go | 252 +++++++++++++++ .../handlers_admin_console_users_test.go | 288 ++++++++++++++++++ backend/internal/server/router.go | 6 + 12 files changed, 780 insertions(+) create mode 100644 backend/internal/adminconsole/message.go create mode 100644 backend/internal/adminconsole/templates/pages/message.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/user_detail.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/users.gohtml create mode 100644 backend/internal/adminconsole/users.go create mode 100644 backend/internal/server/handlers_admin_console_users.go create mode 100644 backend/internal/server/handlers_admin_console_users_test.go diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index a042347..07435f9 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -377,6 +377,7 @@ func run(ctx context.Context) (err error) { CSRF: consoleCSRF, Monitor: opsstatus.NewStore(db), Ready: ready, + Users: userSvc, Logger: logger, }) diff --git a/backend/docs/admin-console.md b/backend/docs/admin-console.md index 801fa84..929234f 100644 --- a/backend/docs/admin-console.md +++ b/backend/docs/admin-console.md @@ -80,6 +80,22 @@ exporters on `backend` and `gateway` are wired and enabled in the dev deployment so a future Prometheus + Grafana stack can scrape them without code changes. +## Pages + +| Path | Method | Purpose | +| --------------------------------- | -------- | -------------------------------------------------------------- | +| `/_gm`, `/_gm/` | GET | Dashboard: health, runtime/mail/notification status, queues. | +| `/_gm/assets/*` | GET | Embedded stylesheet. | +| `/_gm/users` | GET | Paginated account list. | +| `/_gm/users/{id}` | GET | Account detail: profile, entitlement, active sanctions. | +| `/_gm/users/{id}/block` | POST | Apply a permanent block (reason required). | +| `/_gm/users/{id}/entitlement` | POST | Set the entitlement tier. | +| `/_gm/users/{id}/soft-delete` | POST | Soft-delete the account (cascades). | + +Each page reuses the same service layer as the corresponding `/api/v1/admin/*` +JSON endpoint; the console adds no business logic. Unblocking a user is not yet +available because the JSON admin API exposes no remove-sanction endpoint. + ## Configuration | Variable | Where | Notes | diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css index 1fcec80..a34bc8d 100644 --- a/backend/internal/adminconsole/assets/console.css +++ b/backend/internal/adminconsole/assets/console.css @@ -69,3 +69,32 @@ h1 { font-size: 1.4rem; margin: 0 0 0.4rem; } .errors ul { margin: 0; padding-left: 1.1rem; color: var(--danger); } .ok { color: var(--ok); } .bad { color: var(--danger); } + +.list { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin-bottom: 1rem; } +.list th, .list td { text-align: left; padding: 0.35rem 0.6rem; border-bottom: 1px solid var(--line); } +.list th { color: var(--ink-dim); font-weight: 600; } +.list tr:hover td { background: var(--panel-hi); } +.pager { display: flex; gap: 1rem; align-items: center; color: var(--ink-dim); } +.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.8rem; } +.form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); } +.form input, .form select { + background: var(--bg); + color: var(--ink); + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.35rem 0.5rem; + font: inherit; +} +button { + background: var(--accent); + color: #06121f; + border: 0; + border-radius: 6px; + padding: 0.4rem 0.9rem; + font: inherit; + font-weight: 600; + cursor: pointer; +} +button:hover { filter: brightness(1.1); } +button.danger { background: var(--danger); color: #1a0606; } +code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; } diff --git a/backend/internal/adminconsole/message.go b/backend/internal/adminconsole/message.go new file mode 100644 index 0000000..2da0461 --- /dev/null +++ b/backend/internal/adminconsole/message.go @@ -0,0 +1,11 @@ +package adminconsole + +// MessageData is the view model for the generic message page used to render +// not-found, validation, and operation-failure notices. Class selects the CSS +// styling (for example "bad" for errors); BackHref, when set, renders a link +// back to a relevant page. +type MessageData struct { + Message string + Class string + BackHref string +} diff --git a/backend/internal/adminconsole/templates/pages/message.gohtml b/backend/internal/adminconsole/templates/pages/message.gohtml new file mode 100644 index 0000000..8f30abc --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/message.gohtml @@ -0,0 +1,7 @@ +{{define "content" -}} +

{{.Title}}

+{{with .Data}} +

{{.Message}}

+{{if .BackHref}}

« back

{{end}} +{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml new file mode 100644 index 0000000..b01d4fb --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -0,0 +1,68 @@ +{{define "content" -}} +{{$csrf := .CSRFToken}} +{{with .Data}} +

« all users

+

{{.Email}}

+{{if .Deleted}}

This account is soft-deleted.

{{end}} + +
+

Account

+
    +
  • User ID: {{.UserID}}
  • +
  • User name: {{.UserName}}
  • +
  • Display name: {{.DisplayName}}
  • +
  • Preferred language: {{.PreferredLanguage}}
  • +
  • Time zone: {{.TimeZone}}
  • +
  • Declared country: {{.DeclaredCountry}}
  • +
  • Status: {{if .Blocked}}blocked{{else}}active{{end}}
  • +
  • Created: {{.CreatedAt}}
  • +
  • Updated: {{.UpdatedAt}}
  • +
+
+ +
+

Entitlement

+
    +
  • Tier: {{.Tier}} ({{if .IsPaid}}paid{{else}}free{{end}})
  • +
  • Source: {{.EntitlementSource}}
  • +
  • Reason: {{.EntitlementReason}}
  • +
  • Ends: {{if .EntitlementEnds}}{{.EntitlementEnds}}{{else}}—{{end}}
  • +
+
+ + + + + +
+
+ +
+

Active sanctions

+{{if .Sanctions}} + +{{range .Sanctions}}{{end}} +
{{.SanctionCode}}{{.Scope}}{{.ReasonCode}}{{.AppliedAt}}
+{{else}}

none

{{end}} +{{if .Blocked}} +

User is permanently blocked. Unblock is not available in the current admin API.

+{{else}} +
+ + + +
+{{end}} +
+ +
+

Danger zone

+
+ + +
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/users.gohtml b/backend/internal/adminconsole/templates/pages/users.gohtml new file mode 100644 index 0000000..0cc371c --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/users.gohtml @@ -0,0 +1,27 @@ +{{define "content" -}} +

Users

+{{with .Data}} + + + +{{range .Items}} + + + + + + + + +{{else}} + +{{end}} + +
EmailUser nameDisplayTierStatusCreated
{{.Email}}{{.UserName}}{{.DisplayName}}{{.Tier}}{{if .Deleted}}deleted{{else if .Blocked}}blocked{{else}}active{{end}}{{.CreatedAt}}
no users
+ +{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/users.go b/backend/internal/adminconsole/users.go new file mode 100644 index 0000000..dc2b210 --- /dev/null +++ b/backend/internal/adminconsole/users.go @@ -0,0 +1,61 @@ +package adminconsole + +// UserRow is one line in the users list table. +type UserRow struct { + UserID string + Email string + UserName string + DisplayName string + Tier string + Blocked bool + Deleted bool + CreatedAt string +} + +// UsersListData is the view model for the paginated users list. +type UsersListData struct { + Items []UserRow + Page int + PageSize int + Total int + HasPrev bool + HasNext bool + PrevPage int + NextPage int +} + +// SanctionView is one active sanction shown on the user detail page. +type SanctionView struct { + SanctionCode string + Scope string + ReasonCode string + AppliedAt string + ExpiresAt string +} + +// UserDetailData is the view model for a single user's detail page, +// combining the account aggregate with the form option lists. +type UserDetailData struct { + UserID string + Email string + UserName string + DisplayName string + PreferredLanguage string + TimeZone string + DeclaredCountry string + Blocked bool + Deleted bool + CreatedAt string + UpdatedAt string + + Tier string + IsPaid bool + EntitlementSource string + EntitlementReason string + EntitlementEnds string + + Sanctions []SanctionView + + // Tiers lists the selectable entitlement tiers for the form. + Tiers []string +} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 1846eb8..6821728 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -28,6 +28,7 @@ type AdminConsoleHandlers struct { assets http.Handler monitor opsstatus.Reader ready func() bool + users UserAdmin logger *zap.Logger } @@ -41,6 +42,7 @@ type AdminConsoleDeps struct { CSRF *adminconsole.CSRF Monitor opsstatus.Reader Ready func() bool + Users UserAdmin Logger *zap.Logger } @@ -77,6 +79,7 @@ func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers { assets: http.StripPrefix("/_gm/assets/", http.FileServer(http.FS(assetsFS))), monitor: deps.Monitor, ready: deps.Ready, + users: deps.Users, logger: logger.Named("http.admin.console"), } } @@ -168,6 +171,17 @@ func (h *AdminConsoleHandlers) render(c *gin.Context, status int, page, activeNa c.Data(status, "text/html; charset=utf-8", buf.Bytes()) } +// renderMessage renders the generic message page (not-found, validation, or +// operation-failure notices). class selects the CSS styling and backHref, when +// non-empty, adds a back link. +func (h *AdminConsoleHandlers) renderMessage(c *gin.Context, status int, activeNav, title, message, class, backHref string) { + h.render(c, status, "message", activeNav, title, adminconsole.MessageData{ + Message: message, + Class: class, + BackHref: backHref, + }) +} + // isSafeHTTPMethod reports whether method is a read-only HTTP method that the // CSRF guard may let through without a token. func isSafeHTTPMethod(method string) bool { diff --git a/backend/internal/server/handlers_admin_console_users.go b/backend/internal/server/handlers_admin_console_users.go new file mode 100644 index 0000000..2ddf065 --- /dev/null +++ b/backend/internal/server/handlers_admin_console_users.go @@ -0,0 +1,252 @@ +package server + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "galaxy/backend/internal/adminconsole" + "galaxy/backend/internal/server/middleware/basicauth" + "galaxy/backend/internal/user" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" +) + +// UserAdmin is the subset of the user service the operator console depends on. +// *user.Service satisfies it; tests supply a fake so the console pages render +// without a database. +type UserAdmin interface { + ListAccounts(ctx context.Context, page, pageSize int) (user.AccountPage, error) + GetAccount(ctx context.Context, userID uuid.UUID) (user.Account, error) + ApplySanction(ctx context.Context, input user.ApplySanctionInput) (user.Account, error) + ApplyEntitlement(ctx context.Context, input user.ApplyEntitlementInput) (user.Account, error) + SoftDelete(ctx context.Context, userID uuid.UUID, actor user.ActorRef) error +} + +// consoleTiers lists the selectable entitlement tiers in display order. +var consoleTiers = []string{user.TierFree, user.TierMonthly, user.TierYearly, user.TierPermanent} + +// UsersList renders GET /_gm/users — the paginated account list. +func (h *AdminConsoleHandlers) UsersList() gin.HandlerFunc { + return func(c *gin.Context) { + if h.users == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/") + return + } + page := parsePositiveQueryInt(c.Query("page"), 1) + pageSize := parsePositiveQueryInt(c.Query("page_size"), 50) + + result, err := h.users.ListAccounts(c.Request.Context(), page, pageSize) + if err != nil { + h.logger.Error("admin console: list users", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "users", "Users", "Failed to load users.", "bad", "/_gm/") + return + } + h.render(c, http.StatusOK, "users", "users", "Users", toUsersListData(result)) + } +} + +// UserDetail renders GET /_gm/users/:user_id. +func (h *AdminConsoleHandlers) UserDetail() gin.HandlerFunc { + return func(c *gin.Context) { + if h.users == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/") + return + } + userID, ok := parseUserIDParam(c) + if !ok { + return + } + account, err := h.users.GetAccount(c.Request.Context(), userID) + if err != nil { + if errors.Is(err, user.ErrAccountNotFound) { + h.renderMessage(c, http.StatusNotFound, "users", "User not found", "No such user, or the account has been soft-deleted.", "bad", "/_gm/users") + return + } + h.logger.Error("admin console: get user", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "users", "Users", "Failed to load the user.", "bad", "/_gm/users") + return + } + h.render(c, http.StatusOK, "user_detail", "users", account.Email, toUserDetailData(account)) + } +} + +// UserBlock handles POST /_gm/users/:user_id/block — applies a permanent block. +func (h *AdminConsoleHandlers) UserBlock() gin.HandlerFunc { + return func(c *gin.Context) { + if h.users == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/") + return + } + userID, ok := parseUserIDParam(c) + if !ok { + return + } + back := "/_gm/users/" + userID.String() + reason := strings.TrimSpace(c.PostForm("reason_code")) + if reason == "" { + h.renderMessage(c, http.StatusBadRequest, "users", "Invalid input", "A reason is required to block a user.", "bad", back) + return + } + _, err := h.users.ApplySanction(c.Request.Context(), user.ApplySanctionInput{ + UserID: userID, + SanctionCode: user.SanctionCodePermanentBlock, + Scope: "account", + ReasonCode: reason, + Actor: actorFromContext(c), + }) + if err != nil { + h.logger.Error("admin console: block user", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "users", "Block failed", "Failed to block the user.", "bad", back) + return + } + c.Redirect(http.StatusSeeOther, back) + } +} + +// UserEntitlement handles POST /_gm/users/:user_id/entitlement. +func (h *AdminConsoleHandlers) UserEntitlement() gin.HandlerFunc { + return func(c *gin.Context) { + if h.users == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/") + return + } + userID, ok := parseUserIDParam(c) + if !ok { + return + } + back := "/_gm/users/" + userID.String() + tier := strings.TrimSpace(c.PostForm("tier")) + source := strings.TrimSpace(c.PostForm("source")) + if source == "" { + source = "admin" + } + _, err := h.users.ApplyEntitlement(c.Request.Context(), user.ApplyEntitlementInput{ + UserID: userID, + Tier: tier, + Source: source, + Actor: actorFromContext(c), + ReasonCode: strings.TrimSpace(c.PostForm("reason_code")), + }) + if err != nil { + if errors.Is(err, user.ErrInvalidInput) { + h.renderMessage(c, http.StatusBadRequest, "users", "Invalid input", "The entitlement request was rejected: check the tier.", "bad", back) + return + } + h.logger.Error("admin console: apply entitlement", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "users", "Entitlement failed", "Failed to update the entitlement.", "bad", back) + return + } + c.Redirect(http.StatusSeeOther, back) + } +} + +// UserSoftDelete handles POST /_gm/users/:user_id/soft-delete. +func (h *AdminConsoleHandlers) UserSoftDelete() gin.HandlerFunc { + return func(c *gin.Context) { + if h.users == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/") + return + } + userID, ok := parseUserIDParam(c) + if !ok { + return + } + if err := h.users.SoftDelete(c.Request.Context(), userID, actorFromContext(c)); err != nil { + if errors.Is(err, user.ErrAccountNotFound) { + h.renderMessage(c, http.StatusNotFound, "users", "User not found", "No such user.", "bad", "/_gm/users") + return + } + // A cascade error does not undo the soft delete; log and proceed. + h.logger.Warn("admin console: soft-delete cascade returned error", zap.Error(err)) + } + c.Redirect(http.StatusSeeOther, "/_gm/users") + } +} + +// actorFromContext builds the admin ActorRef for audit trails from the +// authenticated operator username stored by the Basic Auth middleware. +func actorFromContext(c *gin.Context) user.ActorRef { + username, _ := basicauth.UsernameFromContext(c.Request.Context()) + return user.ActorRef{Type: "admin", ID: username} +} + +// toUsersListData maps an account page into the users list view model. +func toUsersListData(page user.AccountPage) adminconsole.UsersListData { + data := adminconsole.UsersListData{ + Items: make([]adminconsole.UserRow, 0, len(page.Items)), + Page: page.Page, + PageSize: page.PageSize, + Total: page.Total, + PrevPage: page.Page - 1, + NextPage: page.Page + 1, + HasPrev: page.Page > 1, + HasNext: page.Page*page.PageSize < page.Total, + } + for _, account := range page.Items { + data.Items = append(data.Items, adminconsole.UserRow{ + UserID: account.UserID.String(), + Email: account.Email, + UserName: account.UserName, + DisplayName: account.DisplayName, + Tier: account.Entitlement.Tier, + Blocked: account.PermanentBlock, + Deleted: account.DeletedAt != nil, + CreatedAt: fmtConsoleTime(account.CreatedAt), + }) + } + return data +} + +// toUserDetailData maps an account aggregate into the detail view model. +func toUserDetailData(account user.Account) adminconsole.UserDetailData { + data := adminconsole.UserDetailData{ + UserID: account.UserID.String(), + Email: account.Email, + UserName: account.UserName, + DisplayName: account.DisplayName, + PreferredLanguage: account.PreferredLanguage, + TimeZone: account.TimeZone, + DeclaredCountry: account.DeclaredCountry, + Blocked: account.PermanentBlock, + Deleted: account.DeletedAt != nil, + CreatedAt: fmtConsoleTime(account.CreatedAt), + UpdatedAt: fmtConsoleTime(account.UpdatedAt), + Tier: account.Entitlement.Tier, + IsPaid: account.Entitlement.IsPaid, + EntitlementSource: account.Entitlement.Source, + EntitlementReason: account.Entitlement.ReasonCode, + EntitlementEnds: fmtConsoleTimePtr(account.Entitlement.EndsAt), + Tiers: consoleTiers, + } + for _, sanction := range account.ActiveSanctions { + data.Sanctions = append(data.Sanctions, adminconsole.SanctionView{ + SanctionCode: sanction.SanctionCode, + Scope: sanction.Scope, + ReasonCode: sanction.ReasonCode, + AppliedAt: fmtConsoleTime(sanction.AppliedAt), + ExpiresAt: fmtConsoleTimePtr(sanction.ExpiresAt), + }) + } + return data +} + +// fmtConsoleTime renders a timestamp for display in the console. +func fmtConsoleTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format("2006-01-02 15:04 UTC") +} + +// fmtConsoleTimePtr renders an optional timestamp, returning "" when nil. +func fmtConsoleTimePtr(t *time.Time) string { + if t == nil { + return "" + } + return fmtConsoleTime(*t) +} diff --git a/backend/internal/server/handlers_admin_console_users_test.go b/backend/internal/server/handlers_admin_console_users_test.go new file mode 100644 index 0000000..ffe5b74 --- /dev/null +++ b/backend/internal/server/handlers_admin_console_users_test.go @@ -0,0 +1,288 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "galaxy/backend/internal/adminconsole" + "galaxy/backend/internal/server/middleware/basicauth" + "galaxy/backend/internal/user" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +// fakeUserAdmin records calls so the console handlers can be exercised without +// a database. +type fakeUserAdmin struct { + page user.AccountPage + account user.Account + getErr error + + sanctionCalls int + lastSanction user.ApplySanctionInput + entitlementCall int + lastEntitlement user.ApplyEntitlementInput + softDeleteCalls int + lastSoftActor user.ActorRef +} + +func (f *fakeUserAdmin) ListAccounts(context.Context, int, int) (user.AccountPage, error) { + return f.page, nil +} + +func (f *fakeUserAdmin) GetAccount(context.Context, uuid.UUID) (user.Account, error) { + return f.account, f.getErr +} + +func (f *fakeUserAdmin) ApplySanction(_ context.Context, in user.ApplySanctionInput) (user.Account, error) { + f.sanctionCalls++ + f.lastSanction = in + return f.account, nil +} + +func (f *fakeUserAdmin) ApplyEntitlement(_ context.Context, in user.ApplyEntitlementInput) (user.Account, error) { + f.entitlementCall++ + f.lastEntitlement = in + return f.account, nil +} + +func (f *fakeUserAdmin) SoftDelete(_ context.Context, _ uuid.UUID, actor user.ActorRef) error { + f.softDeleteCalls++ + f.lastSoftActor = actor + return nil +} + +func newUsersConsoleRouter(t *testing.T, users UserAdmin) (http.Handler, *adminconsole.CSRF) { + t.Helper() + csrf := adminconsole.NewCSRF([]byte("test-key")) + handler, err := NewRouter(RouterDependencies{ + Logger: zap.NewNop(), + AdminVerifier: basicauth.NewStaticVerifier("secret"), + AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf, Users: users}), + }) + if err != nil { + t.Fatalf("NewRouter: %v", err) + } + return handler, csrf +} + +func TestConsoleUsersList(t *testing.T) { + fake := &fakeUserAdmin{page: user.AccountPage{ + Items: []user.Account{ + {UserID: uuid.New(), Email: "alice@example.test", UserName: "alice"}, + {UserID: uuid.New(), Email: "bob@example.test", UserName: "bob", PermanentBlock: true}, + }, + Page: 1, PageSize: 50, Total: 2, + }} + router, _ := newUsersConsoleRouter(t, fake) + + req := httptest.NewRequest(http.MethodGet, "/_gm/users", nil) + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, want := range []string{"alice@example.test", "bob@example.test", "blocked", "page 1"} { + if !strings.Contains(body, want) { + t.Errorf("users list missing %q", want) + } + } +} + +func TestConsoleUserDetailRendersForms(t *testing.T) { + id := uuid.New() + fake := &fakeUserAdmin{account: user.Account{ + UserID: id, Email: "alice@example.test", UserName: "alice", + Entitlement: user.EntitlementSnapshot{Tier: user.TierFree}, + }} + router, csrf := newUsersConsoleRouter(t, fake) + + req := httptest.NewRequest(http.MethodGet, "/_gm/users/"+id.String(), nil) + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, want := range []string{ + "alice@example.test", + "Permanently block", + "Update entitlement", + "Soft-delete account", + csrf.Token("ops"), + } { + if !strings.Contains(body, want) { + t.Errorf("user detail missing %q", want) + } + } +} + +func TestConsoleUserDetailNotFound(t *testing.T) { + fake := &fakeUserAdmin{getErr: user.ErrAccountNotFound} + router, _ := newUsersConsoleRouter(t, fake) + + req := httptest.NewRequest(http.MethodGet, "/_gm/users/"+uuid.New().String(), nil) + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + if !strings.Contains(rec.Body.String(), "not found") { + t.Error("expected a not-found message") + } +} + +func TestConsoleUserBlock(t *testing.T) { + id := uuid.New() + fake := &fakeUserAdmin{account: user.Account{UserID: id}} + router, csrf := newUsersConsoleRouter(t, fake) + + form := "_csrf=" + csrf.Token("ops") + "&reason_code=spam" + req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", "https://galaxy.lan") + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String()) + } + if fake.sanctionCalls != 1 { + t.Fatalf("ApplySanction called %d times, want 1", fake.sanctionCalls) + } + if fake.lastSanction.SanctionCode != user.SanctionCodePermanentBlock { + t.Errorf("sanction code = %q, want permanent_block", fake.lastSanction.SanctionCode) + } + if fake.lastSanction.Scope != "account" { + t.Errorf("scope = %q, want account", fake.lastSanction.Scope) + } + if fake.lastSanction.ReasonCode != "spam" { + t.Errorf("reason = %q, want spam", fake.lastSanction.ReasonCode) + } + if fake.lastSanction.Actor.Type != "admin" || fake.lastSanction.Actor.ID != "ops" { + t.Errorf("actor = %+v, want admin/ops", fake.lastSanction.Actor) + } + if fake.lastSanction.UserID != id { + t.Errorf("sanction user id = %s, want %s", fake.lastSanction.UserID, id) + } +} + +func TestConsoleUserBlockMissingReason(t *testing.T) { + id := uuid.New() + fake := &fakeUserAdmin{account: user.Account{UserID: id}} + router, csrf := newUsersConsoleRouter(t, fake) + + form := "_csrf=" + csrf.Token("ops") + req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", "https://galaxy.lan") + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if fake.sanctionCalls != 0 { + t.Errorf("ApplySanction must not be called without a reason") + } +} + +func TestConsoleUserBlockRejectsBadCSRF(t *testing.T) { + id := uuid.New() + fake := &fakeUserAdmin{account: user.Account{UserID: id}} + router, _ := newUsersConsoleRouter(t, fake) + + req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", strings.NewReader("reason_code=spam")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", "https://galaxy.lan") + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + if fake.sanctionCalls != 0 { + t.Errorf("ApplySanction must not run when the CSRF token is missing") + } +} + +func TestConsoleUserEntitlement(t *testing.T) { + id := uuid.New() + fake := &fakeUserAdmin{account: user.Account{UserID: id}} + router, csrf := newUsersConsoleRouter(t, fake) + + form := "_csrf=" + csrf.Token("ops") + "&tier=monthly&source=admin&reason_code=promo" + req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/entitlement", strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", "https://galaxy.lan") + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String()) + } + if fake.entitlementCall != 1 { + t.Fatalf("ApplyEntitlement called %d times, want 1", fake.entitlementCall) + } + if fake.lastEntitlement.Tier != user.TierMonthly { + t.Errorf("tier = %q, want monthly", fake.lastEntitlement.Tier) + } + if fake.lastEntitlement.Actor.ID != "ops" { + t.Errorf("actor id = %q, want ops", fake.lastEntitlement.Actor.ID) + } +} + +func TestConsoleUserSoftDelete(t *testing.T) { + id := uuid.New() + fake := &fakeUserAdmin{account: user.Account{UserID: id}} + router, csrf := newUsersConsoleRouter(t, fake) + + form := "_csrf=" + csrf.Token("ops") + req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/soft-delete", strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", "https://galaxy.lan") + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", rec.Code) + } + if got := rec.Header().Get("Location"); got != "/_gm/users" { + t.Errorf("redirect Location = %q, want /_gm/users", got) + } + if fake.softDeleteCalls != 1 { + t.Fatalf("SoftDelete called %d times, want 1", fake.softDeleteCalls) + } + if fake.lastSoftActor.ID != "ops" { + t.Errorf("soft-delete actor = %q, want ops", fake.lastSoftActor.ID) + } +} + +func TestConsoleUsersUnavailable(t *testing.T) { + router, _ := newUsersConsoleRouter(t, nil) // no user service wired + + req := httptest.NewRequest(http.MethodGet, "/_gm/users", nil) + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", rec.Code) + } +} diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 960e859..6568617 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -388,6 +388,12 @@ func registerAdminConsoleRoutes(router *gin.Engine, deps RouterDependencies) { group.GET("/assets/*filepath", deps.AdminConsole.Asset()) group.GET("", deps.AdminConsole.Dashboard()) group.GET("/", deps.AdminConsole.Dashboard()) + + group.GET("/users", deps.AdminConsole.UsersList()) + group.GET("/users/:user_id", deps.AdminConsole.UserDetail()) + group.POST("/users/:user_id/block", deps.AdminConsole.UserBlock()) + group.POST("/users/:user_id/entitlement", deps.AdminConsole.UserEntitlement()) + group.POST("/users/:user_id/soft-delete", deps.AdminConsole.UserSoftDelete()) } // allowedMethodsForPath returns the comma-separated list of methods From ecfb2d33515d3f334ce0e500f346433eb772844b Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 31 May 2026 20:25:28 +0200 Subject: [PATCH 4/6] =?UTF-8?q?feat(admin-console):=20Stage=204=20?= =?UTF-8?q?=E2=80=94=20games=20&=20runtimes=20domain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the games, runtime, and engine-version pages over the existing lobby, runtime, and engine-version services (no new business logic). - GET/POST /_gm/games list + create public game - GET /_gm/games/{id} detail incl. runtime snapshot - POST /_gm/games/{id}/force-start|stop game state actions - POST /_gm/games/{id}/ban-member ban a member (uuid + reason) - POST /_gm/games/{id}/runtime/restart|patch|force-next-turn - GET/POST /_gm/engine-versions registry + register - POST /_gm/engine-versions/{ver}/disable disable a version Console depends on GameAdmin / RuntimeAdmin / EngineVersionAdmin interfaces (satisfied by the concrete services) so the pages render in tests without a database. Collection-mutating POSTs are mounted on the collection path to avoid a static-vs-param route conflict in gin. Writes flow through the CSRF guard and redirect back; the create form parses datetime-local as UTC. Tests: list/detail (with and without a runtime), create (visibility/owner/time assertions), force-start (+ bad-CSRF), ban-member (+ bad uuid), runtime patch (+ missing version), engine-version list/register/disable, and unavailable. Docs: backend/docs/admin-console.md page inventory extended. --- backend/cmd/backend/main.go | 11 +- backend/docs/admin-console.md | 17 +- .../internal/adminconsole/assets/console.css | 2 + backend/internal/adminconsole/games.go | 67 +++ .../templates/pages/engine_versions.gohtml | 30 ++ .../templates/pages/game_detail.gohtml | 65 +++ .../adminconsole/templates/pages/games.gohtml | 43 ++ .../internal/server/handlers_admin_console.go | 33 +- .../server/handlers_admin_console_games.go | 423 ++++++++++++++++++ .../handlers_admin_console_games_test.go | 353 +++++++++++++++ backend/internal/server/router.go | 14 + 11 files changed, 1040 insertions(+), 18 deletions(-) create mode 100644 backend/internal/adminconsole/games.go create mode 100644 backend/internal/adminconsole/templates/pages/engine_versions.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/game_detail.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/games.gohtml create mode 100644 backend/internal/server/handlers_admin_console_games.go create mode 100644 backend/internal/server/handlers_admin_console_games_test.go diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 07435f9..3c2ab5a 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -375,10 +375,13 @@ func run(ctx context.Context) (err error) { } adminConsoleHandlers := backendserver.NewAdminConsoleHandlers(backendserver.AdminConsoleDeps{ CSRF: consoleCSRF, - Monitor: opsstatus.NewStore(db), - Ready: ready, - Users: userSvc, - Logger: logger, + Monitor: opsstatus.NewStore(db), + Ready: ready, + Users: userSvc, + Games: lobbySvc, + Runtime: runtimeSvc, + EngineVersions: engineVersionSvc, + Logger: logger, }) handler, err := backendserver.NewRouter(backendserver.RouterDependencies{ diff --git a/backend/docs/admin-console.md b/backend/docs/admin-console.md index 929234f..d181a81 100644 --- a/backend/docs/admin-console.md +++ b/backend/docs/admin-console.md @@ -91,10 +91,23 @@ changes. | `/_gm/users/{id}/block` | POST | Apply a permanent block (reason required). | | `/_gm/users/{id}/entitlement` | POST | Set the entitlement tier. | | `/_gm/users/{id}/soft-delete` | POST | Soft-delete the account (cascades). | +| `/_gm/games` | GET/POST | Paginated game list; POST creates a public game. | +| `/_gm/games/{id}` | GET | Game detail with the runtime snapshot. | +| `/_gm/games/{id}/force-start` | POST | Force-start the game. | +| `/_gm/games/{id}/force-stop` | POST | Force-stop the game. | +| `/_gm/games/{id}/ban-member` | POST | Ban a member (user id + reason). | +| `/_gm/games/{id}/runtime/restart` | POST | Restart the engine container. | +| `/_gm/games/{id}/runtime/patch` | POST | Patch the runtime to a target version. | +| `/_gm/games/{id}/runtime/force-next-turn` | POST | Force the next turn now. | +| `/_gm/engine-versions` | GET/POST | Version registry; POST registers a version. | +| `/_gm/engine-versions/{ver}/disable` | POST | Disable a registered version. | Each page reuses the same service layer as the corresponding `/api/v1/admin/*` -JSON endpoint; the console adds no business logic. Unblocking a user is not yet -available because the JSON admin API exposes no remove-sanction endpoint. +JSON endpoint; the console adds no business logic. Collection-mutating POSTs are +mounted on the collection path (`POST /_gm/games`, `POST /_gm/engine-versions`) +so a static action segment never collides with a path parameter in the gin +router. Unblocking a user is not yet available because the JSON admin API +exposes no remove-sanction endpoint. ## Configuration diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css index a34bc8d..b4550df 100644 --- a/backend/internal/adminconsole/assets/console.css +++ b/backend/internal/adminconsole/assets/console.css @@ -98,3 +98,5 @@ button { button:hover { filter: brightness(1.1); } button.danger { background: var(--danger); color: #1a0606; } code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; } +.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; } +.actions form { margin: 0; } diff --git a/backend/internal/adminconsole/games.go b/backend/internal/adminconsole/games.go new file mode 100644 index 0000000..eb733ff --- /dev/null +++ b/backend/internal/adminconsole/games.go @@ -0,0 +1,67 @@ +package adminconsole + +// GameRow is one line in the games list table. +type GameRow struct { + GameID string + GameName string + Visibility string + Status string + Owner string + Players string + TurnSchedule string + CreatedAt string +} + +// GamesListData is the view model for the paginated games list. +type GamesListData struct { + Items []GameRow + Page int + PageSize int + Total int + HasPrev bool + HasNext bool + PrevPage int + NextPage int +} + +// GameDetailData is the view model for a single game, combining the lobby +// record with the runtime snapshot and the available actions. +type GameDetailData struct { + GameID string + GameName string + Description string + Visibility string + Status string + Owner string + MinPlayers int32 + MaxPlayers int32 + StartGapHours int32 + StartGapPlayers int32 + TurnSchedule string + TargetEngineVersion string + EnrollmentEndsAt string + CreatedAt string + StartedAt string + FinishedAt string + + HasRuntime bool + RuntimeStatus string + CurrentEngineVersion string + EngineHealth string + CurrentTurn int32 + NextGenerationAt string + Paused bool +} + +// EngineVersionRow is one line in the engine-version registry table. +type EngineVersionRow struct { + Version string + ImageRef string + Enabled bool + CreatedAt string +} + +// EngineVersionsData is the view model for the engine-version registry page. +type EngineVersionsData struct { + Items []EngineVersionRow +} diff --git a/backend/internal/adminconsole/templates/pages/engine_versions.gohtml b/backend/internal/adminconsole/templates/pages/engine_versions.gohtml new file mode 100644 index 0000000..c68e7c3 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/engine_versions.gohtml @@ -0,0 +1,30 @@ +{{define "content" -}} +{{$csrf := .CSRFToken}} +

Engine versions

+{{with .Data}} + + + +{{range .Items}} + + + + + + + +{{else}}{{end}} + +
VersionImageEnabledCreated
{{.Version}}{{.ImageRef}}{{if .Enabled}}yes{{else}}no{{end}}{{.CreatedAt}}{{if .Enabled}}
{{end}}
no engine versions
+{{end}} +
+

Register version

+
+ + + + + +
+
+{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/game_detail.gohtml b/backend/internal/adminconsole/templates/pages/game_detail.gohtml new file mode 100644 index 0000000..990a96b --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/game_detail.gohtml @@ -0,0 +1,65 @@ +{{define "content" -}} +{{$csrf := .CSRFToken}} +{{with .Data}} +

« all games

+

{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}

+ +
+

Game

+
    +
  • Game ID: {{.GameID}}
  • +
  • Visibility: {{.Visibility}}
  • +
  • Status: {{.Status}}
  • +
  • Owner: {{.Owner}}
  • +
  • Players: {{.MinPlayers}}–{{.MaxPlayers}}
  • +
  • Start gap: {{.StartGapHours}}h / {{.StartGapPlayers}} players
  • +
  • Turn schedule: {{.TurnSchedule}}
  • +
  • Target engine: {{.TargetEngineVersion}}
  • +
  • Enrollment ends: {{.EnrollmentEndsAt}}
  • +
  • Created: {{.CreatedAt}}
  • +
  • Started: {{if .StartedAt}}{{.StartedAt}}{{else}}—{{end}}
  • +
  • Finished: {{if .FinishedAt}}{{.FinishedAt}}{{else}}—{{end}}
  • +
+{{if .Description}}

{{.Description}}

{{end}} +
+
+
+
+
+ +
+

Runtime

+{{if .HasRuntime}} +
    +
  • Status: {{.RuntimeStatus}}
  • +
  • Engine version: {{.CurrentEngineVersion}}
  • +
  • Engine health: {{.EngineHealth}}
  • +
  • Current turn: {{.CurrentTurn}}
  • +
  • Next generation: {{if .NextGenerationAt}}{{.NextGenerationAt}}{{else}}—{{end}}
  • +
  • Paused: {{if .Paused}}yes{{else}}no{{end}}
  • +
+
+
+
+
+
+ + + +
+{{else}} +

No runtime record for this game yet.

+{{end}} +
+ +
+

Ban member

+
+ + + + +
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/games.gohtml b/backend/internal/adminconsole/templates/pages/games.gohtml new file mode 100644 index 0000000..d213cdb --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/games.gohtml @@ -0,0 +1,43 @@ +{{define "content" -}} +{{$csrf := .CSRFToken}} +

Games

+{{with .Data}} + + + +{{range .Items}} + + + + + + + + + +{{else}}{{end}} + +
NameVisibilityStatusOwnerPlayersScheduleCreated
{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}{{.Visibility}}{{.Status}}{{.Owner}}{{.Players}}{{.TurnSchedule}}{{.CreatedAt}}
no games
+ +{{end}} +
+

Create public game

+
+ + + + + + + + + + + +
+
+{{- end}} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 6821728..cf9b834 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -26,10 +26,13 @@ type AdminConsoleHandlers struct { renderer *adminconsole.Renderer csrf *adminconsole.CSRF assets http.Handler - monitor opsstatus.Reader - ready func() bool - users UserAdmin - logger *zap.Logger + monitor opsstatus.Reader + ready func() bool + users UserAdmin + games GameAdmin + runtime RuntimeAdmin + engineVersions EngineVersionAdmin + logger *zap.Logger } // AdminConsoleDeps bundles the collaborators for the operator console. Every @@ -40,10 +43,13 @@ type AdminConsoleHandlers struct { type AdminConsoleDeps struct { Renderer *adminconsole.Renderer CSRF *adminconsole.CSRF - Monitor opsstatus.Reader - Ready func() bool - Users UserAdmin - Logger *zap.Logger + Monitor opsstatus.Reader + Ready func() bool + Users UserAdmin + Games GameAdmin + Runtime RuntimeAdmin + EngineVersions EngineVersionAdmin + Logger *zap.Logger } // NewAdminConsoleHandlers constructs the console handler set from deps. It @@ -77,10 +83,13 @@ func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers { renderer: renderer, csrf: csrf, assets: http.StripPrefix("/_gm/assets/", http.FileServer(http.FS(assetsFS))), - monitor: deps.Monitor, - ready: deps.Ready, - users: deps.Users, - logger: logger.Named("http.admin.console"), + monitor: deps.Monitor, + ready: deps.Ready, + users: deps.Users, + games: deps.Games, + runtime: deps.Runtime, + engineVersions: deps.EngineVersions, + logger: logger.Named("http.admin.console"), } } diff --git a/backend/internal/server/handlers_admin_console_games.go b/backend/internal/server/handlers_admin_console_games.go new file mode 100644 index 0000000..b1ac5b9 --- /dev/null +++ b/backend/internal/server/handlers_admin_console_games.go @@ -0,0 +1,423 @@ +package server + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "galaxy/backend/internal/adminconsole" + "galaxy/backend/internal/lobby" + "galaxy/backend/internal/runtime" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" +) + +// GameAdmin is the subset of the lobby service the console uses for games. +type GameAdmin interface { + ListAdminGames(ctx context.Context, page, pageSize int) (lobby.GamePage, error) + GetGame(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error) + CreateGame(ctx context.Context, input lobby.CreateGameInput) (lobby.GameRecord, error) + AdminForceStart(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error) + AdminForceStop(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error) + AdminBanMember(ctx context.Context, gameID, userID uuid.UUID, reason string) (lobby.Membership, error) +} + +// RuntimeAdmin is the subset of the runtime service the console uses. +type RuntimeAdmin interface { + GetRuntime(ctx context.Context, gameID uuid.UUID) (runtime.RuntimeRecord, error) + AdminRestart(ctx context.Context, gameID uuid.UUID) (runtime.OperationLog, error) + AdminPatch(ctx context.Context, gameID uuid.UUID, targetVersion string) (runtime.OperationLog, error) + AdminForceNextTurn(ctx context.Context, gameID uuid.UUID) (runtime.OperationLog, error) +} + +// EngineVersionAdmin is the subset of the engine-version service the console uses. +type EngineVersionAdmin interface { + List(ctx context.Context) ([]runtime.EngineVersion, error) + Register(ctx context.Context, in runtime.RegisterInput) (runtime.EngineVersion, error) + Disable(ctx context.Context, version string) (runtime.EngineVersion, error) +} + +// GamesList renders GET /_gm/games. +func (h *AdminConsoleHandlers) GamesList() gin.HandlerFunc { + return func(c *gin.Context) { + if h.games == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/") + return + } + page := parsePositiveQueryInt(c.Query("page"), 1) + pageSize := parsePositiveQueryInt(c.Query("page_size"), 50) + result, err := h.games.ListAdminGames(c.Request.Context(), page, pageSize) + if err != nil { + h.logger.Error("admin console: list games", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "games", "Games", "Failed to load games.", "bad", "/_gm/") + return + } + h.render(c, http.StatusOK, "games", "games", "Games", toGamesListData(result)) + } +} + +// GameCreate handles POST /_gm/games — create a public game. +func (h *AdminConsoleHandlers) GameCreate() gin.HandlerFunc { + return func(c *gin.Context) { + if h.games == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/") + return + } + enrollmentEndsAt, err := parseConsoleDateTime(c.PostForm("enrollment_ends_at")) + if err != nil { + h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "Enrollment end must be a valid date/time.", "bad", "/_gm/games") + return + } + game, err := h.games.CreateGame(c.Request.Context(), lobby.CreateGameInput{ + OwnerUserID: nil, + Visibility: lobby.VisibilityPublic, + GameName: strings.TrimSpace(c.PostForm("game_name")), + Description: strings.TrimSpace(c.PostForm("description")), + MinPlayers: formInt32(c, "min_players"), + MaxPlayers: formInt32(c, "max_players"), + StartGapHours: formInt32(c, "start_gap_hours"), + StartGapPlayers: formInt32(c, "start_gap_players"), + EnrollmentEndsAt: enrollmentEndsAt, + TurnSchedule: strings.TrimSpace(c.PostForm("turn_schedule")), + TargetEngineVersion: strings.TrimSpace(c.PostForm("target_engine_version")), + }) + if err != nil { + if errors.Is(err, lobby.ErrInvalidInput) { + h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "The game could not be created: check the fields.", "bad", "/_gm/games") + return + } + h.logger.Error("admin console: create game", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "games", "Create failed", "Failed to create the game.", "bad", "/_gm/games") + return + } + c.Redirect(http.StatusSeeOther, "/_gm/games/"+game.GameID.String()) + } +} + +// GameDetail renders GET /_gm/games/:game_id with the runtime snapshot. +func (h *AdminConsoleHandlers) GameDetail() gin.HandlerFunc { + return func(c *gin.Context) { + if h.games == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/") + return + } + gameID, ok := parseGameIDParam(c) + if !ok { + return + } + game, err := h.games.GetGame(c.Request.Context(), gameID) + if err != nil { + if errors.Is(err, lobby.ErrNotFound) { + h.renderMessage(c, http.StatusNotFound, "games", "Game not found", "No such game.", "bad", "/_gm/games") + return + } + h.logger.Error("admin console: get game", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "games", "Games", "Failed to load the game.", "bad", "/_gm/games") + return + } + + var runtimeRecord *runtime.RuntimeRecord + if h.runtime != nil { + if record, rtErr := h.runtime.GetRuntime(c.Request.Context(), gameID); rtErr == nil { + runtimeRecord = &record + } + } + h.render(c, http.StatusOK, "game_detail", "games", game.GameName, toGameDetailData(game, runtimeRecord)) + } +} + +// GameForceStart handles POST /_gm/games/:game_id/force-start. +func (h *AdminConsoleHandlers) GameForceStart() gin.HandlerFunc { + return h.gameAction("force-start", func(ctx context.Context, gameID uuid.UUID) error { + _, err := h.games.AdminForceStart(ctx, gameID) + return err + }) +} + +// GameForceStop handles POST /_gm/games/:game_id/force-stop. +func (h *AdminConsoleHandlers) GameForceStop() gin.HandlerFunc { + return h.gameAction("force-stop", func(ctx context.Context, gameID uuid.UUID) error { + _, err := h.games.AdminForceStop(ctx, gameID) + return err + }) +} + +// GameBanMember handles POST /_gm/games/:game_id/ban-member. +func (h *AdminConsoleHandlers) GameBanMember() gin.HandlerFunc { + return func(c *gin.Context) { + if h.games == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/") + return + } + gameID, ok := parseGameIDParam(c) + if !ok { + return + } + back := "/_gm/games/" + gameID.String() + userID, err := uuid.Parse(strings.TrimSpace(c.PostForm("user_id"))) + if err != nil { + h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "User ID must be a valid UUID.", "bad", back) + return + } + if _, err := h.games.AdminBanMember(c.Request.Context(), gameID, userID, strings.TrimSpace(c.PostForm("reason"))); err != nil { + h.logger.Error("admin console: ban member", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "games", "Ban failed", "Failed to ban the member.", "bad", back) + return + } + c.Redirect(http.StatusSeeOther, back) + } +} + +// RuntimeRestart handles POST /_gm/games/:game_id/runtime/restart. +func (h *AdminConsoleHandlers) RuntimeRestart() gin.HandlerFunc { + return h.runtimeAction("restart", func(ctx context.Context, gameID uuid.UUID) error { + _, err := h.runtime.AdminRestart(ctx, gameID) + return err + }) +} + +// RuntimeForceNextTurn handles POST /_gm/games/:game_id/runtime/force-next-turn. +func (h *AdminConsoleHandlers) RuntimeForceNextTurn() gin.HandlerFunc { + return h.runtimeAction("force-next-turn", func(ctx context.Context, gameID uuid.UUID) error { + _, err := h.runtime.AdminForceNextTurn(ctx, gameID) + return err + }) +} + +// RuntimePatch handles POST /_gm/games/:game_id/runtime/patch. +func (h *AdminConsoleHandlers) RuntimePatch() gin.HandlerFunc { + return func(c *gin.Context) { + if h.runtime == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "games", "Runtime", "Runtime administration is not available.", "bad", "/_gm/games") + return + } + gameID, ok := parseGameIDParam(c) + if !ok { + return + } + back := "/_gm/games/" + gameID.String() + target := strings.TrimSpace(c.PostForm("target_version")) + if target == "" { + h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "A target version is required.", "bad", back) + return + } + if _, err := h.runtime.AdminPatch(c.Request.Context(), gameID, target); err != nil { + h.logger.Error("admin console: runtime patch", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "games", "Patch failed", "Failed to patch the runtime.", "bad", back) + return + } + c.Redirect(http.StatusSeeOther, back) + } +} + +// gameAction is the shared shape for game-state POST actions that take only the +// game id and redirect back to the detail page. +func (h *AdminConsoleHandlers) gameAction(label string, run func(context.Context, uuid.UUID) error) gin.HandlerFunc { + return func(c *gin.Context) { + if h.games == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/") + return + } + gameID, ok := parseGameIDParam(c) + if !ok { + return + } + back := "/_gm/games/" + gameID.String() + if err := run(c.Request.Context(), gameID); err != nil { + h.logger.Error("admin console: game "+label, zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "games", "Action failed", "The "+label+" action failed.", "bad", back) + return + } + c.Redirect(http.StatusSeeOther, back) + } +} + +// runtimeAction is the shared shape for runtime POST actions that take only the +// game id and redirect back to the detail page. +func (h *AdminConsoleHandlers) runtimeAction(label string, run func(context.Context, uuid.UUID) error) gin.HandlerFunc { + return func(c *gin.Context) { + if h.runtime == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "games", "Runtime", "Runtime administration is not available.", "bad", "/_gm/games") + return + } + gameID, ok := parseGameIDParam(c) + if !ok { + return + } + back := "/_gm/games/" + gameID.String() + if err := run(c.Request.Context(), gameID); err != nil { + h.logger.Error("admin console: runtime "+label, zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "games", "Action failed", "The runtime "+label+" action failed.", "bad", back) + return + } + c.Redirect(http.StatusSeeOther, back) + } +} + +// EngineVersionsList renders GET /_gm/engine-versions. +func (h *AdminConsoleHandlers) EngineVersionsList() gin.HandlerFunc { + return func(c *gin.Context) { + if h.engineVersions == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/") + return + } + items, err := h.engineVersions.List(c.Request.Context()) + if err != nil { + h.logger.Error("admin console: list engine versions", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Engine versions", "Failed to load engine versions.", "bad", "/_gm/") + return + } + h.render(c, http.StatusOK, "engine_versions", "games", "Engine versions", toEngineVersionsData(items)) + } +} + +// EngineVersionRegister handles POST /_gm/engine-versions. +func (h *AdminConsoleHandlers) EngineVersionRegister() gin.HandlerFunc { + return func(c *gin.Context) { + if h.engineVersions == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/") + return + } + enabled := c.PostForm("enabled") == "true" + _, err := h.engineVersions.Register(c.Request.Context(), runtime.RegisterInput{ + Version: strings.TrimSpace(c.PostForm("version")), + ImageRef: strings.TrimSpace(c.PostForm("image_ref")), + Enabled: &enabled, + }) + if err != nil { + if errors.Is(err, runtime.ErrInvalidInput) || errors.Is(err, runtime.ErrEngineVersionTaken) { + h.renderMessage(c, http.StatusBadRequest, "engine-versions", "Invalid input", "The version could not be registered (invalid semver, missing image, or duplicate).", "bad", "/_gm/engine-versions") + return + } + h.logger.Error("admin console: register engine version", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Register failed", "Failed to register the engine version.", "bad", "/_gm/engine-versions") + return + } + c.Redirect(http.StatusSeeOther, "/_gm/engine-versions") + } +} + +// EngineVersionDisable handles POST /_gm/engine-versions/:version/disable. +func (h *AdminConsoleHandlers) EngineVersionDisable() gin.HandlerFunc { + return func(c *gin.Context) { + if h.engineVersions == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/") + return + } + version := strings.TrimSpace(c.Param("version")) + if _, err := h.engineVersions.Disable(c.Request.Context(), version); err != nil { + h.logger.Error("admin console: disable engine version", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Disable failed", "Failed to disable the engine version.", "bad", "/_gm/engine-versions") + return + } + c.Redirect(http.StatusSeeOther, "/_gm/engine-versions") + } +} + +// formInt32 reads a non-negative int32 form field, defaulting to 0. +func formInt32(c *gin.Context, name string) int32 { + parsed, err := strconv.Atoi(strings.TrimSpace(c.PostForm(name))) + if err != nil || parsed < 0 { + return 0 + } + return int32(parsed) +} + +// parseConsoleDateTime parses the value of an +// (or an RFC 3339 timestamp) as UTC. +func parseConsoleDateTime(raw string) (time.Time, error) { + raw = strings.TrimSpace(raw) + for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05", time.RFC3339} { + if t, err := time.ParseInLocation(layout, raw, time.UTC); err == nil { + return t.UTC(), nil + } + } + return time.Time{}, errors.New("invalid date/time") +} + +// toGamesListData maps a game page into the games list view model. +func toGamesListData(page lobby.GamePage) adminconsole.GamesListData { + data := adminconsole.GamesListData{ + Items: make([]adminconsole.GameRow, 0, len(page.Items)), + Page: page.Page, + PageSize: page.PageSize, + Total: page.Total, + PrevPage: page.Page - 1, + NextPage: page.Page + 1, + HasPrev: page.Page > 1, + HasNext: page.Page*page.PageSize < page.Total, + } + for _, game := range page.Items { + data.Items = append(data.Items, adminconsole.GameRow{ + GameID: game.GameID.String(), + GameName: game.GameName, + Visibility: game.Visibility, + Status: game.Status, + Owner: ownerLabel(game.OwnerUserID), + Players: strconv.Itoa(int(game.MinPlayers)) + "–" + strconv.Itoa(int(game.MaxPlayers)), + TurnSchedule: game.TurnSchedule, + CreatedAt: fmtConsoleTime(game.CreatedAt), + }) + } + return data +} + +// toGameDetailData maps a game record and optional runtime record into the +// detail view model. +func toGameDetailData(game lobby.GameRecord, rec *runtime.RuntimeRecord) adminconsole.GameDetailData { + data := adminconsole.GameDetailData{ + GameID: game.GameID.String(), + GameName: game.GameName, + Description: game.Description, + Visibility: game.Visibility, + Status: game.Status, + Owner: ownerLabel(game.OwnerUserID), + MinPlayers: game.MinPlayers, + MaxPlayers: game.MaxPlayers, + StartGapHours: game.StartGapHours, + StartGapPlayers: game.StartGapPlayers, + TurnSchedule: game.TurnSchedule, + TargetEngineVersion: game.TargetEngineVersion, + EnrollmentEndsAt: fmtConsoleTime(game.EnrollmentEndsAt), + CreatedAt: fmtConsoleTime(game.CreatedAt), + StartedAt: fmtConsoleTimePtr(game.StartedAt), + FinishedAt: fmtConsoleTimePtr(game.FinishedAt), + } + if rec != nil { + data.HasRuntime = true + data.RuntimeStatus = rec.Status + data.CurrentEngineVersion = rec.CurrentEngineVersion + data.EngineHealth = rec.EngineHealth + data.CurrentTurn = rec.CurrentTurn + data.NextGenerationAt = fmtConsoleTimePtr(rec.NextGenerationAt) + data.Paused = rec.Paused + } + return data +} + +// toEngineVersionsData maps engine versions into the registry view model. +func toEngineVersionsData(items []runtime.EngineVersion) adminconsole.EngineVersionsData { + data := adminconsole.EngineVersionsData{Items: make([]adminconsole.EngineVersionRow, 0, len(items))} + for _, v := range items { + data.Items = append(data.Items, adminconsole.EngineVersionRow{ + Version: v.Version, + ImageRef: v.ImageRef, + Enabled: v.Enabled, + CreatedAt: fmtConsoleTime(v.CreatedAt), + }) + } + return data +} + +// ownerLabel renders an optional owner id; public games have no owner. +func ownerLabel(ownerID *uuid.UUID) string { + if ownerID == nil { + return "—" + } + return ownerID.String() +} diff --git a/backend/internal/server/handlers_admin_console_games_test.go b/backend/internal/server/handlers_admin_console_games_test.go new file mode 100644 index 0000000..3e31f42 --- /dev/null +++ b/backend/internal/server/handlers_admin_console_games_test.go @@ -0,0 +1,353 @@ +package server + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "galaxy/backend/internal/adminconsole" + "galaxy/backend/internal/lobby" + "galaxy/backend/internal/runtime" + "galaxy/backend/internal/server/middleware/basicauth" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +type fakeGameAdmin struct { + page lobby.GamePage + game lobby.GameRecord + getErr error + created lobby.CreateGameInput + + createCalls int + forceStartCalls int + forceStopCalls int + banCalls int + lastBanUser uuid.UUID + lastBanReason string +} + +func (f *fakeGameAdmin) ListAdminGames(context.Context, int, int) (lobby.GamePage, error) { + return f.page, nil +} +func (f *fakeGameAdmin) GetGame(context.Context, uuid.UUID) (lobby.GameRecord, error) { + return f.game, f.getErr +} +func (f *fakeGameAdmin) CreateGame(_ context.Context, in lobby.CreateGameInput) (lobby.GameRecord, error) { + f.createCalls++ + f.created = in + return f.game, nil +} +func (f *fakeGameAdmin) AdminForceStart(context.Context, uuid.UUID) (lobby.GameRecord, error) { + f.forceStartCalls++ + return f.game, nil +} +func (f *fakeGameAdmin) AdminForceStop(context.Context, uuid.UUID) (lobby.GameRecord, error) { + f.forceStopCalls++ + return f.game, nil +} +func (f *fakeGameAdmin) AdminBanMember(_ context.Context, _, userID uuid.UUID, reason string) (lobby.Membership, error) { + f.banCalls++ + f.lastBanUser = userID + f.lastBanReason = reason + return lobby.Membership{}, nil +} + +type fakeRuntimeAdmin struct { + record runtime.RuntimeRecord + getErr error + restartCalls int + forceNextCalls int + patchCalls int + lastPatchVersion string +} + +func (f *fakeRuntimeAdmin) GetRuntime(context.Context, uuid.UUID) (runtime.RuntimeRecord, error) { + return f.record, f.getErr +} +func (f *fakeRuntimeAdmin) AdminRestart(context.Context, uuid.UUID) (runtime.OperationLog, error) { + f.restartCalls++ + return runtime.OperationLog{}, nil +} +func (f *fakeRuntimeAdmin) AdminPatch(_ context.Context, _ uuid.UUID, target string) (runtime.OperationLog, error) { + f.patchCalls++ + f.lastPatchVersion = target + return runtime.OperationLog{}, nil +} +func (f *fakeRuntimeAdmin) AdminForceNextTurn(context.Context, uuid.UUID) (runtime.OperationLog, error) { + f.forceNextCalls++ + return runtime.OperationLog{}, nil +} + +type fakeEngineVersionAdmin struct { + list []runtime.EngineVersion + registered runtime.RegisterInput + registerCalls int + disableCalls int + lastDisabled string +} + +func (f *fakeEngineVersionAdmin) List(context.Context) ([]runtime.EngineVersion, error) { + return f.list, nil +} +func (f *fakeEngineVersionAdmin) Register(_ context.Context, in runtime.RegisterInput) (runtime.EngineVersion, error) { + f.registerCalls++ + f.registered = in + return runtime.EngineVersion{}, nil +} +func (f *fakeEngineVersionAdmin) Disable(_ context.Context, version string) (runtime.EngineVersion, error) { + f.disableCalls++ + f.lastDisabled = version + return runtime.EngineVersion{}, nil +} + +func newGamesConsoleRouter(t *testing.T, games GameAdmin, rt RuntimeAdmin, ev EngineVersionAdmin) (http.Handler, *adminconsole.CSRF) { + t.Helper() + csrf := adminconsole.NewCSRF([]byte("test-key")) + handler, err := NewRouter(RouterDependencies{ + Logger: zap.NewNop(), + AdminVerifier: basicauth.NewStaticVerifier("secret"), + AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{ + CSRF: csrf, Games: games, Runtime: rt, EngineVersions: ev, + }), + }) + if err != nil { + t.Fatalf("NewRouter: %v", err) + } + return handler, csrf +} + +func consoleGet(t *testing.T, router http.Handler, path string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func consolePost(t *testing.T, router http.Handler, path, form string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan"+path, strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", "https://galaxy.lan") + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func TestConsoleGamesList(t *testing.T) { + games := &fakeGameAdmin{page: lobby.GamePage{ + Items: []lobby.GameRecord{{GameID: uuid.New(), GameName: "Nova", Visibility: "public", Status: "enrollment_open"}}, + Page: 1, PageSize: 50, Total: 1, + }} + router, _ := newGamesConsoleRouter(t, games, nil, nil) + + rec := consoleGet(t, router, "/_gm/games") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + for _, want := range []string{"Nova", "public", "enrollment_open", "Create public game"} { + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("games list missing %q", want) + } + } +} + +func TestConsoleGameDetailWithRuntime(t *testing.T) { + id := uuid.New() + games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id, GameName: "Nova", Status: "running"}} + rt := &fakeRuntimeAdmin{record: runtime.RuntimeRecord{GameID: id, Status: "running", CurrentEngineVersion: "0.1.0", CurrentTurn: 7}} + router, csrf := newGamesConsoleRouter(t, games, rt, nil) + + rec := consoleGet(t, router, "/_gm/games/"+id.String()) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + for _, want := range []string{"Nova", "Force start", "Force stop", "0.1.0", "Patch", "Ban member", csrf.Token("ops")} { + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("game detail missing %q", want) + } + } +} + +func TestConsoleGameDetailNoRuntime(t *testing.T) { + id := uuid.New() + games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id, GameName: "Nova"}} + rt := &fakeRuntimeAdmin{getErr: errors.New("not found")} + router, _ := newGamesConsoleRouter(t, games, rt, nil) + + rec := consoleGet(t, router, "/_gm/games/"+id.String()) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "No runtime record") { + t.Error("expected a no-runtime note") + } +} + +func TestConsoleGameCreate(t *testing.T) { + id := uuid.New() + games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}} + router, csrf := newGamesConsoleRouter(t, games, nil, nil) + + form := "_csrf=" + csrf.Token("ops") + + "&game_name=Nova&description=d&min_players=2&max_players=8&start_gap_hours=0&start_gap_players=0" + + "&enrollment_ends_at=2030-01-02T15:04&turn_schedule=@every+24h&target_engine_version=0.1.0" + rec := consolePost(t, router, "/_gm/games", form) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Location"); got != "/_gm/games/"+id.String() { + t.Errorf("redirect = %q, want detail page", got) + } + if games.createCalls != 1 { + t.Fatalf("CreateGame called %d times, want 1", games.createCalls) + } + if games.created.Visibility != lobby.VisibilityPublic { + t.Errorf("visibility = %q, want public", games.created.Visibility) + } + if games.created.GameName != "Nova" { + t.Errorf("game name = %q", games.created.GameName) + } + if games.created.EnrollmentEndsAt.Year() != 2030 { + t.Errorf("enrollment year = %d, want 2030", games.created.EnrollmentEndsAt.Year()) + } + if games.created.OwnerUserID != nil { + t.Error("public game must have a nil owner") + } +} + +func TestConsoleGameForceStart(t *testing.T) { + id := uuid.New() + games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}} + router, csrf := newGamesConsoleRouter(t, games, nil, nil) + + rec := consolePost(t, router, "/_gm/games/"+id.String()+"/force-start", "_csrf="+csrf.Token("ops")) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", rec.Code) + } + if games.forceStartCalls != 1 { + t.Errorf("AdminForceStart called %d times, want 1", games.forceStartCalls) + } +} + +func TestConsoleGameForceStartRejectsBadCSRF(t *testing.T) { + id := uuid.New() + games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}} + router, _ := newGamesConsoleRouter(t, games, nil, nil) + + rec := consolePost(t, router, "/_gm/games/"+id.String()+"/force-start", "") + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + if games.forceStartCalls != 0 { + t.Error("force-start must not run without a CSRF token") + } +} + +func TestConsoleGameBanMember(t *testing.T) { + gameID := uuid.New() + target := uuid.New() + games := &fakeGameAdmin{game: lobby.GameRecord{GameID: gameID}} + router, csrf := newGamesConsoleRouter(t, games, nil, nil) + + form := "_csrf=" + csrf.Token("ops") + "&user_id=" + target.String() + "&reason=cheating" + rec := consolePost(t, router, "/_gm/games/"+gameID.String()+"/ban-member", form) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String()) + } + if games.banCalls != 1 || games.lastBanUser != target || games.lastBanReason != "cheating" { + t.Errorf("ban recorded %d user=%s reason=%q", games.banCalls, games.lastBanUser, games.lastBanReason) + } +} + +func TestConsoleGameBanMemberRejectsBadUUID(t *testing.T) { + gameID := uuid.New() + games := &fakeGameAdmin{game: lobby.GameRecord{GameID: gameID}} + router, csrf := newGamesConsoleRouter(t, games, nil, nil) + + rec := consolePost(t, router, "/_gm/games/"+gameID.String()+"/ban-member", "_csrf="+csrf.Token("ops")+"&user_id=not-a-uuid") + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if games.banCalls != 0 { + t.Error("ban must not run with an invalid user id") + } +} + +func TestConsoleRuntimePatch(t *testing.T) { + id := uuid.New() + games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}} + rt := &fakeRuntimeAdmin{} + router, csrf := newGamesConsoleRouter(t, games, rt, nil) + + rec := consolePost(t, router, "/_gm/games/"+id.String()+"/runtime/patch", "_csrf="+csrf.Token("ops")+"&target_version=0.1.1") + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", rec.Code) + } + if rt.patchCalls != 1 || rt.lastPatchVersion != "0.1.1" { + t.Errorf("patch recorded %d version=%q", rt.patchCalls, rt.lastPatchVersion) + } +} + +func TestConsoleRuntimePatchMissingVersion(t *testing.T) { + id := uuid.New() + games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}} + rt := &fakeRuntimeAdmin{} + router, csrf := newGamesConsoleRouter(t, games, rt, nil) + + rec := consolePost(t, router, "/_gm/games/"+id.String()+"/runtime/patch", "_csrf="+csrf.Token("ops")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if rt.patchCalls != 0 { + t.Error("patch must not run without a target version") + } +} + +func TestConsoleEngineVersions(t *testing.T) { + ev := &fakeEngineVersionAdmin{list: []runtime.EngineVersion{{Version: "0.1.0", ImageRef: "img:0.1.0", Enabled: true}}} + router, csrf := newGamesConsoleRouter(t, nil, nil, ev) + + rec := consoleGet(t, router, "/_gm/engine-versions") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + for _, want := range []string{"0.1.0", "img:0.1.0", "Register version", "Disable"} { + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("engine versions page missing %q", want) + } + } + + rec = consolePost(t, router, "/_gm/engine-versions", "_csrf="+csrf.Token("ops")+"&version=0.2.0&image_ref=img:0.2.0&enabled=true") + if rec.Code != http.StatusSeeOther { + t.Fatalf("register status = %d, want 303", rec.Code) + } + if ev.registerCalls != 1 || ev.registered.Version != "0.2.0" || ev.registered.Enabled == nil || !*ev.registered.Enabled { + t.Errorf("register recorded %d version=%q enabled=%v", ev.registerCalls, ev.registered.Version, ev.registered.Enabled) + } + + rec = consolePost(t, router, "/_gm/engine-versions/0.1.0/disable", "_csrf="+csrf.Token("ops")) + if rec.Code != http.StatusSeeOther { + t.Fatalf("disable status = %d, want 303", rec.Code) + } + if ev.disableCalls != 1 || ev.lastDisabled != "0.1.0" { + t.Errorf("disable recorded %d version=%q", ev.disableCalls, ev.lastDisabled) + } +} + +func TestConsoleGamesUnavailable(t *testing.T) { + router, _ := newGamesConsoleRouter(t, nil, nil, nil) + + rec := consoleGet(t, router, "/_gm/games") + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", rec.Code) + } +} diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 6568617..bab38d2 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -394,6 +394,20 @@ func registerAdminConsoleRoutes(router *gin.Engine, deps RouterDependencies) { group.POST("/users/:user_id/block", deps.AdminConsole.UserBlock()) group.POST("/users/:user_id/entitlement", deps.AdminConsole.UserEntitlement()) group.POST("/users/:user_id/soft-delete", deps.AdminConsole.UserSoftDelete()) + + group.GET("/games", deps.AdminConsole.GamesList()) + group.POST("/games", deps.AdminConsole.GameCreate()) + group.GET("/games/:game_id", deps.AdminConsole.GameDetail()) + group.POST("/games/:game_id/force-start", deps.AdminConsole.GameForceStart()) + group.POST("/games/:game_id/force-stop", deps.AdminConsole.GameForceStop()) + group.POST("/games/:game_id/ban-member", deps.AdminConsole.GameBanMember()) + group.POST("/games/:game_id/runtime/restart", deps.AdminConsole.RuntimeRestart()) + group.POST("/games/:game_id/runtime/patch", deps.AdminConsole.RuntimePatch()) + group.POST("/games/:game_id/runtime/force-next-turn", deps.AdminConsole.RuntimeForceNextTurn()) + + group.GET("/engine-versions", deps.AdminConsole.EngineVersionsList()) + group.POST("/engine-versions", deps.AdminConsole.EngineVersionRegister()) + group.POST("/engine-versions/:version/disable", deps.AdminConsole.EngineVersionDisable()) } // allowedMethodsForPath returns the comma-separated list of methods From 87a272166bcfef119a7fdc4642a6bb88786e2e74 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 31 May 2026 20:31:16 +0200 Subject: [PATCH 5/6] =?UTF-8?q?feat(admin-console):=20Stage=205=20?= =?UTF-8?q?=E2=80=94=20operators=20(admin=20accounts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the operator-management page over *admin.Service (no new business logic). - GET/POST /_gm/operators list + create operator - POST /_gm/operators/{user}/disable|enable toggle access - POST /_gm/operators/{user}/reset-password set a new password Console depends on an OperatorAdmin interface (satisfied by *admin.Service) so the page renders in tests without a database. Create POST is mounted on the collection path; per-row disable/enable/reset are guarded by the CSRF middleware and redirect back. Passwords are never logged. Tests: list render, create (+ username/password assertions), username-taken conflict, disable/enable, reset (+ password assertion), missing-password 400, bad-CSRF 403, and unavailable 503. Docs: backend/docs/admin-console.md page inventory extended. --- backend/cmd/backend/main.go | 1 + backend/docs/admin-console.md | 4 + backend/internal/adminconsole/operators.go | 14 ++ .../templates/pages/operators.gohtml | 38 ++++ .../internal/server/handlers_admin_console.go | 3 + .../handlers_admin_console_operators.go | 149 ++++++++++++++++ .../handlers_admin_console_operators_test.go | 166 ++++++++++++++++++ backend/internal/server/router.go | 6 + 8 files changed, 381 insertions(+) create mode 100644 backend/internal/adminconsole/operators.go create mode 100644 backend/internal/adminconsole/templates/pages/operators.gohtml create mode 100644 backend/internal/server/handlers_admin_console_operators.go create mode 100644 backend/internal/server/handlers_admin_console_operators_test.go diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 3c2ab5a..79968e1 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -381,6 +381,7 @@ func run(ctx context.Context) (err error) { Games: lobbySvc, Runtime: runtimeSvc, EngineVersions: engineVersionSvc, + Operators: adminSvc, Logger: logger, }) diff --git a/backend/docs/admin-console.md b/backend/docs/admin-console.md index d181a81..9694a11 100644 --- a/backend/docs/admin-console.md +++ b/backend/docs/admin-console.md @@ -101,6 +101,10 @@ changes. | `/_gm/games/{id}/runtime/force-next-turn` | POST | Force the next turn now. | | `/_gm/engine-versions` | GET/POST | Version registry; POST registers a version. | | `/_gm/engine-versions/{ver}/disable` | POST | Disable a registered version. | +| `/_gm/operators` | GET/POST | Admin-account list; POST creates an operator. | +| `/_gm/operators/{user}/disable` | POST | Disable an operator. | +| `/_gm/operators/{user}/enable` | POST | Re-enable an operator. | +| `/_gm/operators/{user}/reset-password` | POST | Reset an operator's password. | Each page reuses the same service layer as the corresponding `/api/v1/admin/*` JSON endpoint; the console adds no business logic. Collection-mutating POSTs are diff --git a/backend/internal/adminconsole/operators.go b/backend/internal/adminconsole/operators.go new file mode 100644 index 0000000..e3545fe --- /dev/null +++ b/backend/internal/adminconsole/operators.go @@ -0,0 +1,14 @@ +package adminconsole + +// OperatorRow is one line in the operators (admin accounts) table. +type OperatorRow struct { + Username string + CreatedAt string + LastUsedAt string + Disabled bool +} + +// OperatorsData is the view model for the operators page. +type OperatorsData struct { + Items []OperatorRow +} diff --git a/backend/internal/adminconsole/templates/pages/operators.gohtml b/backend/internal/adminconsole/templates/pages/operators.gohtml new file mode 100644 index 0000000..f944ace --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/operators.gohtml @@ -0,0 +1,38 @@ +{{define "content" -}} +{{$csrf := .CSRFToken}} +

Operators

+{{with .Data}} + + + +{{range .Items}} + + + + + + + +{{else}}{{end}} + +
UsernameStatusCreatedLast usedActions
{{.Username}}{{if .Disabled}}disabled{{else}}active{{end}}{{.CreatedAt}}{{if .LastUsedAt}}{{.LastUsedAt}}{{else}}—{{end}} +
+{{if .Disabled}} +
+{{else}} +
+{{end}} +
+
+
no operators
+{{end}} +
+

Create operator

+
+ + + + +
+
+{{- end}} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index cf9b834..d58e4ee 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -32,6 +32,7 @@ type AdminConsoleHandlers struct { games GameAdmin runtime RuntimeAdmin engineVersions EngineVersionAdmin + operators OperatorAdmin logger *zap.Logger } @@ -49,6 +50,7 @@ type AdminConsoleDeps struct { Games GameAdmin Runtime RuntimeAdmin EngineVersions EngineVersionAdmin + Operators OperatorAdmin Logger *zap.Logger } @@ -89,6 +91,7 @@ func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers { games: deps.Games, runtime: deps.Runtime, engineVersions: deps.EngineVersions, + operators: deps.Operators, logger: logger.Named("http.admin.console"), } } diff --git a/backend/internal/server/handlers_admin_console_operators.go b/backend/internal/server/handlers_admin_console_operators.go new file mode 100644 index 0000000..e02c36b --- /dev/null +++ b/backend/internal/server/handlers_admin_console_operators.go @@ -0,0 +1,149 @@ +package server + +import ( + "context" + "errors" + "net/http" + "strings" + + "galaxy/backend/internal/admin" + "galaxy/backend/internal/adminconsole" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// OperatorAdmin is the subset of the admin-account service the console uses. +// *admin.Service satisfies it. +type OperatorAdmin interface { + List(ctx context.Context) ([]admin.Admin, error) + Create(ctx context.Context, in admin.CreateInput) (admin.Admin, error) + Disable(ctx context.Context, username string) (admin.Admin, error) + Enable(ctx context.Context, username string) (admin.Admin, error) + ResetPassword(ctx context.Context, username, password string) (admin.Admin, error) +} + +// OperatorsList renders GET /_gm/operators. +func (h *AdminConsoleHandlers) OperatorsList() gin.HandlerFunc { + return func(c *gin.Context) { + if h.operators == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/") + return + } + admins, err := h.operators.List(c.Request.Context()) + if err != nil { + h.logger.Error("admin console: list operators", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "operators", "Operators", "Failed to load operators.", "bad", "/_gm/") + return + } + h.render(c, http.StatusOK, "operators", "operators", "Operators", toOperatorsData(admins)) + } +} + +// OperatorCreate handles POST /_gm/operators. +func (h *AdminConsoleHandlers) OperatorCreate() gin.HandlerFunc { + return func(c *gin.Context) { + if h.operators == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/") + return + } + _, err := h.operators.Create(c.Request.Context(), admin.CreateInput{ + Username: strings.TrimSpace(c.PostForm("username")), + Password: c.PostForm("password"), + }) + if err != nil { + switch { + case errors.Is(err, admin.ErrUsernameTaken): + h.renderMessage(c, http.StatusConflict, "operators", "Username taken", "That username is already in use.", "bad", "/_gm/operators") + case errors.Is(err, admin.ErrInvalidInput): + h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "Username and password are required.", "bad", "/_gm/operators") + default: + h.logger.Error("admin console: create operator", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "operators", "Create failed", "Failed to create the operator.", "bad", "/_gm/operators") + } + return + } + c.Redirect(http.StatusSeeOther, "/_gm/operators") + } +} + +// OperatorDisable handles POST /_gm/operators/:username/disable. +func (h *AdminConsoleHandlers) OperatorDisable() gin.HandlerFunc { + return h.operatorAction("disable", func(ctx context.Context, username string) error { + _, err := h.operators.Disable(ctx, username) + return err + }) +} + +// OperatorEnable handles POST /_gm/operators/:username/enable. +func (h *AdminConsoleHandlers) OperatorEnable() gin.HandlerFunc { + return h.operatorAction("enable", func(ctx context.Context, username string) error { + _, err := h.operators.Enable(ctx, username) + return err + }) +} + +// OperatorResetPassword handles POST /_gm/operators/:username/reset-password. +func (h *AdminConsoleHandlers) OperatorResetPassword() gin.HandlerFunc { + return func(c *gin.Context) { + if h.operators == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/") + return + } + username := c.Param("username") + password := c.PostForm("password") + if strings.TrimSpace(password) == "" { + h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "A new password is required.", "bad", "/_gm/operators") + return + } + if _, err := h.operators.ResetPassword(c.Request.Context(), username, password); err != nil { + if errors.Is(err, admin.ErrNotFound) { + h.renderMessage(c, http.StatusNotFound, "operators", "Operator not found", "No such operator.", "bad", "/_gm/operators") + return + } + if errors.Is(err, admin.ErrInvalidInput) { + h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "The password was rejected.", "bad", "/_gm/operators") + return + } + h.logger.Error("admin console: reset operator password", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "operators", "Reset failed", "Failed to reset the password.", "bad", "/_gm/operators") + return + } + c.Redirect(http.StatusSeeOther, "/_gm/operators") + } +} + +// operatorAction is the shared shape for operator POST actions that take only +// the username and redirect back to the list. +func (h *AdminConsoleHandlers) operatorAction(label string, run func(context.Context, string) error) gin.HandlerFunc { + return func(c *gin.Context) { + if h.operators == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/") + return + } + if err := run(c.Request.Context(), c.Param("username")); err != nil { + if errors.Is(err, admin.ErrNotFound) { + h.renderMessage(c, http.StatusNotFound, "operators", "Operator not found", "No such operator.", "bad", "/_gm/operators") + return + } + h.logger.Error("admin console: operator "+label, zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "operators", "Action failed", "The "+label+" action failed.", "bad", "/_gm/operators") + return + } + c.Redirect(http.StatusSeeOther, "/_gm/operators") + } +} + +// toOperatorsData maps admin accounts into the operators view model. +func toOperatorsData(admins []admin.Admin) adminconsole.OperatorsData { + data := adminconsole.OperatorsData{Items: make([]adminconsole.OperatorRow, 0, len(admins))} + for _, a := range admins { + data.Items = append(data.Items, adminconsole.OperatorRow{ + Username: a.Username, + CreatedAt: fmtConsoleTime(a.CreatedAt), + LastUsedAt: fmtConsoleTimePtr(a.LastUsedAt), + Disabled: a.DisabledAt != nil, + }) + } + return data +} diff --git a/backend/internal/server/handlers_admin_console_operators_test.go b/backend/internal/server/handlers_admin_console_operators_test.go new file mode 100644 index 0000000..c2f325b --- /dev/null +++ b/backend/internal/server/handlers_admin_console_operators_test.go @@ -0,0 +1,166 @@ +package server + +import ( + "context" + "net/http" + "strings" + "testing" + + "galaxy/backend/internal/admin" + "galaxy/backend/internal/adminconsole" + "galaxy/backend/internal/server/middleware/basicauth" + + "go.uber.org/zap" +) + +type fakeOperatorAdmin struct { + list []admin.Admin + createErr error + + created admin.CreateInput + createCalls int + disableCalls int + enableCalls int + resetCalls int + lastResetUser string + lastResetPass string +} + +func (f *fakeOperatorAdmin) List(context.Context) ([]admin.Admin, error) { return f.list, nil } +func (f *fakeOperatorAdmin) Create(_ context.Context, in admin.CreateInput) (admin.Admin, error) { + f.createCalls++ + f.created = in + if f.createErr != nil { + return admin.Admin{}, f.createErr + } + return admin.Admin{Username: in.Username}, nil +} +func (f *fakeOperatorAdmin) Disable(_ context.Context, username string) (admin.Admin, error) { + f.disableCalls++ + return admin.Admin{Username: username}, nil +} +func (f *fakeOperatorAdmin) Enable(_ context.Context, username string) (admin.Admin, error) { + f.enableCalls++ + return admin.Admin{Username: username}, nil +} +func (f *fakeOperatorAdmin) ResetPassword(_ context.Context, username, password string) (admin.Admin, error) { + f.resetCalls++ + f.lastResetUser = username + f.lastResetPass = password + return admin.Admin{Username: username}, nil +} + +func operatorsRouter(t *testing.T, operators OperatorAdmin) (http.Handler, *adminconsole.CSRF) { + t.Helper() + csrf := adminconsole.NewCSRF([]byte("test-key")) + handler, err := NewRouter(RouterDependencies{ + Logger: zap.NewNop(), + AdminVerifier: basicauth.NewStaticVerifier("secret"), + AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf, Operators: operators}), + }) + if err != nil { + t.Fatalf("NewRouter: %v", err) + } + return handler, csrf +} + +func TestConsoleOperatorsList(t *testing.T) { + fake := &fakeOperatorAdmin{list: []admin.Admin{{Username: "root"}}} + router, _ := operatorsRouter(t, fake) + + rec := consoleGet(t, router, "/_gm/operators") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + for _, want := range []string{"root", "Create operator", "Reset"} { + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("operators page missing %q", want) + } + } +} + +func TestConsoleOperatorCreate(t *testing.T) { + fake := &fakeOperatorAdmin{} + router, csrf := operatorsRouter(t, fake) + + rec := consolePost(t, router, "/_gm/operators", "_csrf="+csrf.Token("ops")+"&username=mod&password=s3cret") + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String()) + } + if fake.createCalls != 1 || fake.created.Username != "mod" || fake.created.Password != "s3cret" { + t.Errorf("create recorded %d username=%q", fake.createCalls, fake.created.Username) + } +} + +func TestConsoleOperatorCreateConflict(t *testing.T) { + fake := &fakeOperatorAdmin{createErr: admin.ErrUsernameTaken} + router, csrf := operatorsRouter(t, fake) + + rec := consolePost(t, router, "/_gm/operators", "_csrf="+csrf.Token("ops")+"&username=root&password=x") + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409", rec.Code) + } +} + +func TestConsoleOperatorDisableEnable(t *testing.T) { + fake := &fakeOperatorAdmin{} + router, csrf := operatorsRouter(t, fake) + + if rec := consolePost(t, router, "/_gm/operators/root/disable", "_csrf="+csrf.Token("ops")); rec.Code != http.StatusSeeOther { + t.Fatalf("disable status = %d, want 303", rec.Code) + } + if rec := consolePost(t, router, "/_gm/operators/root/enable", "_csrf="+csrf.Token("ops")); rec.Code != http.StatusSeeOther { + t.Fatalf("enable status = %d, want 303", rec.Code) + } + if fake.disableCalls != 1 || fake.enableCalls != 1 { + t.Errorf("disable=%d enable=%d, want 1/1", fake.disableCalls, fake.enableCalls) + } +} + +func TestConsoleOperatorResetPassword(t *testing.T) { + fake := &fakeOperatorAdmin{} + router, csrf := operatorsRouter(t, fake) + + rec := consolePost(t, router, "/_gm/operators/root/reset-password", "_csrf="+csrf.Token("ops")+"&password=newpass") + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", rec.Code) + } + if fake.resetCalls != 1 || fake.lastResetUser != "root" || fake.lastResetPass != "newpass" { + t.Errorf("reset recorded %d user=%q", fake.resetCalls, fake.lastResetUser) + } +} + +func TestConsoleOperatorResetPasswordMissing(t *testing.T) { + fake := &fakeOperatorAdmin{} + router, csrf := operatorsRouter(t, fake) + + rec := consolePost(t, router, "/_gm/operators/root/reset-password", "_csrf="+csrf.Token("ops")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if fake.resetCalls != 0 { + t.Error("reset must not run without a password") + } +} + +func TestConsoleOperatorRejectsBadCSRF(t *testing.T) { + fake := &fakeOperatorAdmin{} + router, _ := operatorsRouter(t, fake) + + rec := consolePost(t, router, "/_gm/operators/root/disable", "") + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + if fake.disableCalls != 0 { + t.Error("disable must not run without a CSRF token") + } +} + +func TestConsoleOperatorsUnavailable(t *testing.T) { + router, _ := operatorsRouter(t, nil) + + rec := consoleGet(t, router, "/_gm/operators") + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", rec.Code) + } +} diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index bab38d2..4e5c3e5 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -408,6 +408,12 @@ func registerAdminConsoleRoutes(router *gin.Engine, deps RouterDependencies) { group.GET("/engine-versions", deps.AdminConsole.EngineVersionsList()) group.POST("/engine-versions", deps.AdminConsole.EngineVersionRegister()) group.POST("/engine-versions/:version/disable", deps.AdminConsole.EngineVersionDisable()) + + group.GET("/operators", deps.AdminConsole.OperatorsList()) + group.POST("/operators", deps.AdminConsole.OperatorCreate()) + group.POST("/operators/:username/disable", deps.AdminConsole.OperatorDisable()) + group.POST("/operators/:username/enable", deps.AdminConsole.OperatorEnable()) + group.POST("/operators/:username/reset-password", deps.AdminConsole.OperatorResetPassword()) } // allowedMethodsForPath returns the comma-separated list of methods From 7cac910de4c4a47156bbfcc97e5dca618aa219f8 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 31 May 2026 20:43:12 +0200 Subject: [PATCH 6/6] =?UTF-8?q?feat(admin-console):=20Stage=206=20?= =?UTF-8?q?=E2=80=94=20mail=20&=20notifications=20domain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the mail, notifications, and broadcast pages over the mail, notification, and diplomail services (no new business logic), completing the operator console. - GET /_gm/mail deliveries (paginated) + dead-letters - GET /_gm/mail/deliveries/{id} delivery detail + attempts - POST /_gm/mail/deliveries/{id}/resend re-enqueue a non-sent delivery - GET /_gm/notifications notifications + dead-letters + malformed - GET/POST /_gm/broadcast multi-game admin diplomatic broadcast Console depends on MailAdmin / NotificationAdmin / DiplomailAdmin interfaces (satisfied by the concrete services); pages render in tests without a database. Delivery detail and dead-letters live under /_gm/mail/deliveries/* and /_gm/mail/... static segments to avoid a param/static route conflict. Resend and broadcast flow through the CSRF guard. Tests: mail page, delivery detail (+ not-found), resend (+ bad-CSRF), notifications overview, broadcast form + send (input assertions) + bad game ids, and unavailable. Plus an integration test that drives /_gm end to end through the real gateway → backend (401 challenge + authenticated dashboard). Docs: backend/docs/admin-console.md page inventory completed. --- backend/cmd/backend/main.go | 3 + backend/docs/admin-console.md | 5 + .../internal/adminconsole/assets/console.css | 1 + backend/internal/adminconsole/mail.go | 86 +++++ .../templates/pages/broadcast.gohtml | 21 ++ .../adminconsole/templates/pages/mail.gohtml | 32 ++ .../templates/pages/mail_delivery.gohtml | 33 ++ .../templates/pages/notifications.gohtml | 27 ++ .../internal/server/handlers_admin_console.go | 9 + .../server/handlers_admin_console_mail.go | 327 ++++++++++++++++++ .../handlers_admin_console_mail_test.go | 242 +++++++++++++ backend/internal/server/router.go | 7 + integration/admin_console_test.go | 63 ++++ 13 files changed, 856 insertions(+) create mode 100644 backend/internal/adminconsole/mail.go create mode 100644 backend/internal/adminconsole/templates/pages/broadcast.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/mail.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/mail_delivery.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/notifications.gohtml create mode 100644 backend/internal/server/handlers_admin_console_mail.go create mode 100644 backend/internal/server/handlers_admin_console_mail_test.go create mode 100644 integration/admin_console_test.go diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 79968e1..8d87527 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -382,6 +382,9 @@ func run(ctx context.Context) (err error) { Runtime: runtimeSvc, EngineVersions: engineVersionSvc, Operators: adminSvc, + Mail: mailSvc, + Notifications: notifSvc, + Diplomail: diplomailSvc, Logger: logger, }) diff --git a/backend/docs/admin-console.md b/backend/docs/admin-console.md index 9694a11..74e908d 100644 --- a/backend/docs/admin-console.md +++ b/backend/docs/admin-console.md @@ -105,6 +105,11 @@ changes. | `/_gm/operators/{user}/disable` | POST | Disable an operator. | | `/_gm/operators/{user}/enable` | POST | Re-enable an operator. | | `/_gm/operators/{user}/reset-password` | POST | Reset an operator's password. | +| `/_gm/mail` | GET | Mail deliveries (paginated) + a dead-letter snapshot. | +| `/_gm/mail/deliveries/{id}` | GET | Delivery detail with its attempts. | +| `/_gm/mail/deliveries/{id}/resend`| POST | Re-enqueue a non-sent delivery. | +| `/_gm/notifications` | GET | Notifications, dead-letters, and malformed intents overview. | +| `/_gm/broadcast` | GET/POST | Admin multi-game diplomatic broadcast. | Each page reuses the same service layer as the corresponding `/api/v1/admin/*` JSON endpoint; the console adds no business logic. Collection-mutating POSTs are diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css index b4550df..6b276b7 100644 --- a/backend/internal/adminconsole/assets/console.css +++ b/backend/internal/adminconsole/assets/console.css @@ -100,3 +100,4 @@ button.danger { background: var(--danger); color: #1a0606; } code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; } .actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; } .actions form { margin: 0; } +.subnav { color: var(--ink-dim); margin: -0.3rem 0 1rem; font-size: 0.9rem; } diff --git a/backend/internal/adminconsole/mail.go b/backend/internal/adminconsole/mail.go new file mode 100644 index 0000000..f6983d7 --- /dev/null +++ b/backend/internal/adminconsole/mail.go @@ -0,0 +1,86 @@ +package adminconsole + +// MailDeliveryRow is one line in the mail deliveries table. +type MailDeliveryRow struct { + DeliveryID string + Template string + Status string + Attempts int32 + NextAttempt string + Created string +} + +// MailDeadLetterRow is one line in the mail dead-letters table. +type MailDeadLetterRow struct { + DeliveryID string + Reason string + Archived string +} + +// MailData is the view model for the mail page: a paginated deliveries list +// plus a snapshot of dead-letters. +type MailData struct { + Deliveries []MailDeliveryRow + DeadLetters []MailDeadLetterRow + Page int + PageSize int + Total int64 + HasPrev bool + HasNext bool + PrevPage int + NextPage int +} + +// MailAttemptRow is one delivery attempt on the mail detail page. +type MailAttemptRow struct { + AttemptNo int32 + Outcome string + Started string + Finished string + Error string +} + +// MailDeliveryDetail is the view model for a single delivery. +type MailDeliveryDetail struct { + DeliveryID string + Template string + Status string + Attempts int32 + NextAttempt string + LastError string + Created string + Sent string + DeadLettered string + CanResend bool + AttemptRows []MailAttemptRow +} + +// NotificationRow is one line in the notifications table. +type NotificationRow struct { + NotificationID string + Kind string + UserID string + Created string +} + +// NotificationDeadLetterRow is one line in the notification dead-letters table. +type NotificationDeadLetterRow struct { + NotificationID string + RouteID string + Reason string + Archived string +} + +// MalformedRow is one line in the malformed-intents table. +type MalformedRow struct { + ID string + Reason string + Received string +} + +// NotificationsData is the view model for the notifications overview page. +type NotificationsData struct { + Notifications []NotificationRow + DeadLetters []NotificationDeadLetterRow + Malformed []MalformedRow +} diff --git a/backend/internal/adminconsole/templates/pages/broadcast.gohtml b/backend/internal/adminconsole/templates/pages/broadcast.gohtml new file mode 100644 index 0000000..317ac33 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/broadcast.gohtml @@ -0,0 +1,21 @@ +{{define "content" -}} +{{$csrf := .CSRFToken}} +

Broadcast

+ +
+

Admin broadcast

+
+ + + + + + + +
+
+{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/mail.gohtml b/backend/internal/adminconsole/templates/pages/mail.gohtml new file mode 100644 index 0000000..ea4a93e --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/mail.gohtml @@ -0,0 +1,32 @@ +{{define "content" -}} +

Mail

+ +{{with .Data}} +
+

Deliveries

+ + + +{{range .Deliveries}} + +{{else}}{{end}} + +
DeliveryTemplateStatusAttemptsNext attemptCreated
{{.DeliveryID}}{{.Template}}{{.Status}}{{.Attempts}}{{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}}{{.Created}}
no deliveries
+ +
+
+

Dead-letters

+ + + +{{range .DeadLetters}} +{{else}}{{end}} + +
DeliveryReasonArchived
{{.DeliveryID}}{{.Reason}}{{.Archived}}
no dead-letters
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/mail_delivery.gohtml b/backend/internal/adminconsole/templates/pages/mail_delivery.gohtml new file mode 100644 index 0000000..d587259 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/mail_delivery.gohtml @@ -0,0 +1,33 @@ +{{define "content" -}} +{{$csrf := .CSRFToken}} +{{with .Data}} +

« mail

+

Delivery

+
+
    +
  • Delivery ID: {{.DeliveryID}}
  • +
  • Template: {{.Template}}
  • +
  • Status: {{.Status}}
  • +
  • Attempts: {{.Attempts}}
  • +
  • Next attempt: {{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}}
  • +
  • Created: {{.Created}}
  • +
  • Sent: {{if .Sent}}{{.Sent}}{{else}}—{{end}}
  • +
  • Dead-lettered: {{if .DeadLettered}}{{.DeadLettered}}{{else}}—{{end}}
  • +
  • Last error: {{if .LastError}}{{.LastError}}{{else}}—{{end}}
  • +
+{{if .CanResend}} +
+{{else}}

Already sent — resend is not available.

{{end}} +
+
+

Attempts

+ + + +{{range .AttemptRows}} +{{else}}{{end}} + +
#OutcomeStartedFinishedError
{{.AttemptNo}}{{.Outcome}}{{.Started}}{{if .Finished}}{{.Finished}}{{else}}—{{end}}{{.Error}}
no attempts
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/notifications.gohtml b/backend/internal/adminconsole/templates/pages/notifications.gohtml new file mode 100644 index 0000000..eb1d27d --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/notifications.gohtml @@ -0,0 +1,27 @@ +{{define "content" -}} +

Notifications

+ +{{with .Data}} +
+

Recent notifications

+ +{{range .Notifications}} +{{else}}{{end}} +
IDKindUserCreated
{{.NotificationID}}{{.Kind}}{{.UserID}}{{.Created}}
none
+
+
+

Dead-letters

+ +{{range .DeadLetters}} +{{else}}{{end}} +
NotificationRouteReasonArchived
{{.NotificationID}}{{.RouteID}}{{.Reason}}{{.Archived}}
none
+
+
+

Malformed intents

+ +{{range .Malformed}} +{{else}}{{end}} +
IDReasonReceived
{{.ID}}{{.Reason}}{{.Received}}
none
+
+{{end}} +{{- end}} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index d58e4ee..a23badc 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -33,6 +33,9 @@ type AdminConsoleHandlers struct { runtime RuntimeAdmin engineVersions EngineVersionAdmin operators OperatorAdmin + mail MailAdmin + notifications NotificationAdmin + diplomail DiplomailAdmin logger *zap.Logger } @@ -51,6 +54,9 @@ type AdminConsoleDeps struct { Runtime RuntimeAdmin EngineVersions EngineVersionAdmin Operators OperatorAdmin + Mail MailAdmin + Notifications NotificationAdmin + Diplomail DiplomailAdmin Logger *zap.Logger } @@ -92,6 +98,9 @@ func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers { runtime: deps.Runtime, engineVersions: deps.EngineVersions, operators: deps.Operators, + mail: deps.Mail, + notifications: deps.Notifications, + diplomail: deps.Diplomail, logger: logger.Named("http.admin.console"), } } diff --git a/backend/internal/server/handlers_admin_console_mail.go b/backend/internal/server/handlers_admin_console_mail.go new file mode 100644 index 0000000..cfee71a --- /dev/null +++ b/backend/internal/server/handlers_admin_console_mail.go @@ -0,0 +1,327 @@ +package server + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "galaxy/backend/internal/adminconsole" + "galaxy/backend/internal/diplomail" + "galaxy/backend/internal/mail" + "galaxy/backend/internal/notification" + "galaxy/backend/internal/server/clientip" + "galaxy/backend/internal/server/middleware/basicauth" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" +) + +// MailAdmin is the subset of the mail service the console uses. +type MailAdmin interface { + AdminListDeliveries(ctx context.Context, page, pageSize int) (mail.AdminListDeliveriesPage, error) + AdminGetDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error) + AdminListAttempts(ctx context.Context, deliveryID uuid.UUID) ([]mail.Attempt, error) + AdminResendDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error) + AdminListDeadLetters(ctx context.Context, page, pageSize int) (mail.AdminListDeadLettersPage, error) +} + +// NotificationAdmin is the subset of the notification service the console uses. +type NotificationAdmin interface { + AdminListNotifications(ctx context.Context, page, pageSize int) (notification.AdminListNotificationsPage, error) + AdminListDeadLetters(ctx context.Context, page, pageSize int) (notification.AdminListDeadLettersPage, error) + AdminListMalformed(ctx context.Context, page, pageSize int) (notification.AdminListMalformedPage, error) +} + +// DiplomailAdmin is the subset of the diplomail service the console uses. +type DiplomailAdmin interface { + SendAdminMultiGameBroadcast(ctx context.Context, in diplomail.SendMultiGameBroadcastInput) ([]diplomail.Message, int, error) +} + +const consoleSnapshotPageSize = 50 + +// MailPage renders GET /_gm/mail — paginated deliveries plus a dead-letter snapshot. +func (h *AdminConsoleHandlers) MailPage() gin.HandlerFunc { + return func(c *gin.Context) { + if h.mail == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/") + return + } + page := parsePositiveQueryInt(c.Query("page"), 1) + pageSize := parsePositiveQueryInt(c.Query("page_size"), 50) + ctx := c.Request.Context() + + deliveries, err := h.mail.AdminListDeliveries(ctx, page, pageSize) + if err != nil { + h.logger.Error("admin console: list deliveries", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load deliveries.", "bad", "/_gm/") + return + } + dead, err := h.mail.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize) + if err != nil { + h.logger.Error("admin console: list mail dead-letters", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load dead-letters.", "bad", "/_gm/") + return + } + h.render(c, http.StatusOK, "mail", "mail", "Mail", toMailData(deliveries, dead)) + } +} + +// MailDeliveryDetail renders GET /_gm/mail/deliveries/:delivery_id. +func (h *AdminConsoleHandlers) MailDeliveryDetail() gin.HandlerFunc { + return func(c *gin.Context) { + if h.mail == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/") + return + } + deliveryID, ok := parseConsoleDeliveryID(c, h) + if !ok { + return + } + ctx := c.Request.Context() + delivery, err := h.mail.AdminGetDelivery(ctx, deliveryID) + if err != nil { + if errors.Is(err, mail.ErrDeliveryNotFound) { + h.renderMessage(c, http.StatusNotFound, "mail", "Delivery not found", "No such delivery.", "bad", "/_gm/mail") + return + } + h.logger.Error("admin console: get delivery", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load the delivery.", "bad", "/_gm/mail") + return + } + attempts, err := h.mail.AdminListAttempts(ctx, deliveryID) + if err != nil { + h.logger.Error("admin console: list attempts", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load attempts.", "bad", "/_gm/mail") + return + } + h.render(c, http.StatusOK, "mail_delivery", "mail", "Delivery", toMailDeliveryDetail(delivery, attempts)) + } +} + +// MailResend handles POST /_gm/mail/deliveries/:delivery_id/resend. +func (h *AdminConsoleHandlers) MailResend() gin.HandlerFunc { + return func(c *gin.Context) { + if h.mail == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/") + return + } + deliveryID, ok := parseConsoleDeliveryID(c, h) + if !ok { + return + } + back := "/_gm/mail/deliveries/" + deliveryID.String() + if _, err := h.mail.AdminResendDelivery(c.Request.Context(), deliveryID); err != nil { + h.logger.Error("admin console: resend delivery", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "mail", "Resend failed", "Failed to resend the delivery (it may already be sent).", "bad", back) + return + } + c.Redirect(http.StatusSeeOther, back) + } +} + +// NotificationsPage renders GET /_gm/notifications — notifications, dead-letters, +// and malformed intents on one overview page. +func (h *AdminConsoleHandlers) NotificationsPage() gin.HandlerFunc { + return func(c *gin.Context) { + if h.notifications == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Notifications", "Notification administration is not available.", "bad", "/_gm/") + return + } + ctx := c.Request.Context() + notifications, err := h.notifications.AdminListNotifications(ctx, 1, consoleSnapshotPageSize) + if err != nil { + h.logger.Error("admin console: list notifications", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load notifications.", "bad", "/_gm/") + return + } + dead, err := h.notifications.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize) + if err != nil { + h.logger.Error("admin console: list notification dead-letters", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load dead-letters.", "bad", "/_gm/") + return + } + malformed, err := h.notifications.AdminListMalformed(ctx, 1, consoleSnapshotPageSize) + if err != nil { + h.logger.Error("admin console: list malformed intents", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load malformed intents.", "bad", "/_gm/") + return + } + h.render(c, http.StatusOK, "notifications", "mail", "Notifications", toNotificationsData(notifications, dead, malformed)) + } +} + +// BroadcastForm renders GET /_gm/broadcast. +func (h *AdminConsoleHandlers) BroadcastForm() gin.HandlerFunc { + return func(c *gin.Context) { + if h.diplomail == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/") + return + } + h.render(c, http.StatusOK, "broadcast", "mail", "Broadcast", nil) + } +} + +// BroadcastSend handles POST /_gm/broadcast — multi-game admin broadcast. +func (h *AdminConsoleHandlers) BroadcastSend() gin.HandlerFunc { + return func(c *gin.Context) { + if h.diplomail == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/") + return + } + username, _ := basicauth.UsernameFromContext(c.Request.Context()) + gameIDs, err := parseGameIDList(c.PostForm("game_ids")) + if err != nil { + h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "Game IDs must be valid UUIDs.", "bad", "/_gm/broadcast") + return + } + _, total, err := h.diplomail.SendAdminMultiGameBroadcast(c.Request.Context(), diplomail.SendMultiGameBroadcastInput{ + CallerUsername: username, + Scope: strings.TrimSpace(c.PostForm("scope")), + GameIDs: gameIDs, + RecipientScope: strings.TrimSpace(c.PostForm("recipients")), + Subject: strings.TrimSpace(c.PostForm("subject")), + Body: c.PostForm("body"), + SenderIP: clientip.ExtractSourceIP(c), + }) + if err != nil { + if errors.Is(err, diplomail.ErrInvalidInput) { + h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "The broadcast was rejected: check the scope, recipients, and body.", "bad", "/_gm/broadcast") + return + } + h.logger.Error("admin console: broadcast", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "mail", "Broadcast failed", "Failed to send the broadcast.", "bad", "/_gm/broadcast") + return + } + h.renderMessage(c, http.StatusOK, "mail", "Broadcast sent", fmt.Sprintf("Broadcast delivered to %d recipients.", total), "ok", "/_gm/broadcast") + } +} + +// parseConsoleDeliveryID parses the delivery_id path parameter, rendering a +// console message page on failure. +func parseConsoleDeliveryID(c *gin.Context, h *AdminConsoleHandlers) (uuid.UUID, bool) { + parsed, err := uuid.Parse(c.Param("delivery_id")) + if err != nil { + h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "delivery_id must be a valid UUID.", "bad", "/_gm/mail") + return uuid.Nil, false + } + return parsed, true +} + +// parseGameIDList parses a comma-separated list of UUIDs, ignoring blanks. +func parseGameIDList(raw string) ([]uuid.UUID, error) { + fields := strings.Split(raw, ",") + ids := make([]uuid.UUID, 0, len(fields)) + for _, field := range fields { + field = strings.TrimSpace(field) + if field == "" { + continue + } + parsed, err := uuid.Parse(field) + if err != nil { + return nil, err + } + ids = append(ids, parsed) + } + return ids, nil +} + +func toMailData(deliveries mail.AdminListDeliveriesPage, dead mail.AdminListDeadLettersPage) adminconsole.MailData { + data := adminconsole.MailData{ + Deliveries: make([]adminconsole.MailDeliveryRow, 0, len(deliveries.Items)), + DeadLetters: make([]adminconsole.MailDeadLetterRow, 0, len(dead.Items)), + Page: deliveries.Page, + PageSize: deliveries.PageSize, + Total: deliveries.Total, + PrevPage: deliveries.Page - 1, + NextPage: deliveries.Page + 1, + HasPrev: deliveries.Page > 1, + HasNext: int64(deliveries.Page*deliveries.PageSize) < deliveries.Total, + } + for _, d := range deliveries.Items { + data.Deliveries = append(data.Deliveries, adminconsole.MailDeliveryRow{ + DeliveryID: d.DeliveryID.String(), + Template: d.TemplateID, + Status: d.Status, + Attempts: d.Attempts, + NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt), + Created: fmtConsoleTime(d.CreatedAt), + }) + } + for _, d := range dead.Items { + data.DeadLetters = append(data.DeadLetters, adminconsole.MailDeadLetterRow{ + DeliveryID: d.DeliveryID.String(), + Reason: d.Reason, + Archived: fmtConsoleTime(d.ArchivedAt), + }) + } + return data +} + +func toMailDeliveryDetail(d mail.Delivery, attempts []mail.Attempt) adminconsole.MailDeliveryDetail { + detail := adminconsole.MailDeliveryDetail{ + DeliveryID: d.DeliveryID.String(), + Template: d.TemplateID, + Status: d.Status, + Attempts: d.Attempts, + NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt), + LastError: d.LastError, + Created: fmtConsoleTime(d.CreatedAt), + Sent: fmtConsoleTimePtr(d.SentAt), + DeadLettered: fmtConsoleTimePtr(d.DeadLetteredAt), + CanResend: d.Status != mail.StatusSent, + AttemptRows: make([]adminconsole.MailAttemptRow, 0, len(attempts)), + } + for _, a := range attempts { + detail.AttemptRows = append(detail.AttemptRows, adminconsole.MailAttemptRow{ + AttemptNo: a.AttemptNo, + Outcome: a.Outcome, + Started: fmtConsoleTime(a.StartedAt), + Finished: fmtConsoleTimePtr(a.FinishedAt), + Error: a.Error, + }) + } + return detail +} + +func toNotificationsData(notifications notification.AdminListNotificationsPage, dead notification.AdminListDeadLettersPage, malformed notification.AdminListMalformedPage) adminconsole.NotificationsData { + data := adminconsole.NotificationsData{ + Notifications: make([]adminconsole.NotificationRow, 0, len(notifications.Items)), + DeadLetters: make([]adminconsole.NotificationDeadLetterRow, 0, len(dead.Items)), + Malformed: make([]adminconsole.MalformedRow, 0, len(malformed.Items)), + } + for _, n := range notifications.Items { + data.Notifications = append(data.Notifications, adminconsole.NotificationRow{ + NotificationID: n.NotificationID.String(), + Kind: n.Kind, + UserID: optionalUUID(n.UserID), + Created: fmtConsoleTime(n.CreatedAt), + }) + } + for _, d := range dead.Items { + data.DeadLetters = append(data.DeadLetters, adminconsole.NotificationDeadLetterRow{ + NotificationID: d.NotificationID.String(), + RouteID: d.RouteID.String(), + Reason: d.Reason, + Archived: fmtConsoleTime(d.ArchivedAt), + }) + } + for _, m := range malformed.Items { + data.Malformed = append(data.Malformed, adminconsole.MalformedRow{ + ID: m.ID.String(), + Reason: m.Reason, + Received: fmtConsoleTime(m.ReceivedAt), + }) + } + return data +} + +// optionalUUID renders a nullable user id; system-scoped rows have none. +func optionalUUID(id *uuid.UUID) string { + if id == nil { + return "—" + } + return id.String() +} diff --git a/backend/internal/server/handlers_admin_console_mail_test.go b/backend/internal/server/handlers_admin_console_mail_test.go new file mode 100644 index 0000000..449e82f --- /dev/null +++ b/backend/internal/server/handlers_admin_console_mail_test.go @@ -0,0 +1,242 @@ +package server + +import ( + "context" + "net/http" + "strings" + "testing" + "time" + + "galaxy/backend/internal/adminconsole" + "galaxy/backend/internal/diplomail" + "galaxy/backend/internal/mail" + "galaxy/backend/internal/notification" + "galaxy/backend/internal/server/middleware/basicauth" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +type fakeMailAdmin struct { + deliveries mail.AdminListDeliveriesPage + dead mail.AdminListDeadLettersPage + delivery mail.Delivery + getErr error + attempts []mail.Attempt + resendCalls int +} + +func (f *fakeMailAdmin) AdminListDeliveries(context.Context, int, int) (mail.AdminListDeliveriesPage, error) { + return f.deliveries, nil +} +func (f *fakeMailAdmin) AdminGetDelivery(context.Context, uuid.UUID) (mail.Delivery, error) { + return f.delivery, f.getErr +} +func (f *fakeMailAdmin) AdminListAttempts(context.Context, uuid.UUID) ([]mail.Attempt, error) { + return f.attempts, nil +} +func (f *fakeMailAdmin) AdminResendDelivery(context.Context, uuid.UUID) (mail.Delivery, error) { + f.resendCalls++ + return f.delivery, nil +} +func (f *fakeMailAdmin) AdminListDeadLetters(context.Context, int, int) (mail.AdminListDeadLettersPage, error) { + return f.dead, nil +} + +type fakeNotificationAdmin struct { + notifications notification.AdminListNotificationsPage + dead notification.AdminListDeadLettersPage + malformed notification.AdminListMalformedPage +} + +func (f *fakeNotificationAdmin) AdminListNotifications(context.Context, int, int) (notification.AdminListNotificationsPage, error) { + return f.notifications, nil +} +func (f *fakeNotificationAdmin) AdminListDeadLetters(context.Context, int, int) (notification.AdminListDeadLettersPage, error) { + return f.dead, nil +} +func (f *fakeNotificationAdmin) AdminListMalformed(context.Context, int, int) (notification.AdminListMalformedPage, error) { + return f.malformed, nil +} + +type fakeDiplomailAdmin struct { + total int + err error + broadcastCalls int + last diplomail.SendMultiGameBroadcastInput +} + +func (f *fakeDiplomailAdmin) SendAdminMultiGameBroadcast(_ context.Context, in diplomail.SendMultiGameBroadcastInput) ([]diplomail.Message, int, error) { + f.broadcastCalls++ + f.last = in + if f.err != nil { + return nil, 0, f.err + } + return nil, f.total, nil +} + +func mailConsoleRouter(t *testing.T, m MailAdmin, n NotificationAdmin, d DiplomailAdmin) (http.Handler, *adminconsole.CSRF) { + t.Helper() + csrf := adminconsole.NewCSRF([]byte("test-key")) + handler, err := NewRouter(RouterDependencies{ + Logger: zap.NewNop(), + AdminVerifier: basicauth.NewStaticVerifier("secret"), + AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf, Mail: m, Notifications: n, Diplomail: d}), + }) + if err != nil { + t.Fatalf("NewRouter: %v", err) + } + return handler, csrf +} + +func TestConsoleMailPage(t *testing.T) { + id := uuid.New() + m := &fakeMailAdmin{ + deliveries: mail.AdminListDeliveriesPage{ + Items: []mail.Delivery{{DeliveryID: id, TemplateID: "auth.login_code", Status: "pending", CreatedAt: time.Now()}}, + Page: 1, PageSize: 50, Total: 1, + }, + dead: mail.AdminListDeadLettersPage{ + Items: []mail.DeadLetter{{DeliveryID: uuid.New(), Reason: "smtp 550", ArchivedAt: time.Now()}}, + }, + } + router, _ := mailConsoleRouter(t, m, nil, nil) + + rec := consoleGet(t, router, "/_gm/mail") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + for _, want := range []string{"auth.login_code", "pending", "Dead-letters", "smtp 550"} { + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("mail page missing %q", want) + } + } +} + +func TestConsoleMailDeliveryDetail(t *testing.T) { + id := uuid.New() + m := &fakeMailAdmin{ + delivery: mail.Delivery{DeliveryID: id, TemplateID: "auth.login_code", Status: "pending", Attempts: 2}, + attempts: []mail.Attempt{{AttemptNo: 1, Outcome: "transient_failure", StartedAt: time.Now(), Error: "timeout"}}, + } + router, _ := mailConsoleRouter(t, m, nil, nil) + + rec := consoleGet(t, router, "/_gm/mail/deliveries/"+id.String()) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + for _, want := range []string{id.String(), "auth.login_code", "Attempts", "transient_failure", "Resend"} { + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("delivery detail missing %q", want) + } + } +} + +func TestConsoleMailDeliveryDetailNotFound(t *testing.T) { + m := &fakeMailAdmin{getErr: mail.ErrDeliveryNotFound} + router, _ := mailConsoleRouter(t, m, nil, nil) + + rec := consoleGet(t, router, "/_gm/mail/deliveries/"+uuid.New().String()) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } +} + +func TestConsoleMailResend(t *testing.T) { + id := uuid.New() + m := &fakeMailAdmin{delivery: mail.Delivery{DeliveryID: id}} + router, csrf := mailConsoleRouter(t, m, nil, nil) + + rec := consolePost(t, router, "/_gm/mail/deliveries/"+id.String()+"/resend", "_csrf="+csrf.Token("ops")) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", rec.Code) + } + if m.resendCalls != 1 { + t.Errorf("AdminResendDelivery called %d times, want 1", m.resendCalls) + } +} + +func TestConsoleMailResendRejectsBadCSRF(t *testing.T) { + id := uuid.New() + m := &fakeMailAdmin{delivery: mail.Delivery{DeliveryID: id}} + router, _ := mailConsoleRouter(t, m, nil, nil) + + rec := consolePost(t, router, "/_gm/mail/deliveries/"+id.String()+"/resend", "") + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + if m.resendCalls != 0 { + t.Error("resend must not run without a CSRF token") + } +} + +func TestConsoleNotificationsPage(t *testing.T) { + n := &fakeNotificationAdmin{ + notifications: notification.AdminListNotificationsPage{Items: []notification.Notification{{NotificationID: uuid.New(), Kind: "lobby.invite.received"}}}, + dead: notification.AdminListDeadLettersPage{Items: []notification.DeadLetter{{NotificationID: uuid.New(), RouteID: uuid.New(), Reason: "push gone"}}}, + malformed: notification.AdminListMalformedPage{Items: []notification.MalformedIntent{{ID: uuid.New(), Reason: "bad shape"}}}, + } + router, _ := mailConsoleRouter(t, nil, n, nil) + + rec := consoleGet(t, router, "/_gm/notifications") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + for _, want := range []string{"lobby.invite.received", "push gone", "bad shape", "Malformed intents"} { + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("notifications page missing %q", want) + } + } +} + +func TestConsoleBroadcastForm(t *testing.T) { + router, _ := mailConsoleRouter(t, nil, nil, &fakeDiplomailAdmin{}) + + rec := consoleGet(t, router, "/_gm/broadcast") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Send broadcast") { + t.Error("broadcast form missing") + } +} + +func TestConsoleBroadcastSend(t *testing.T) { + d := &fakeDiplomailAdmin{total: 5} + router, csrf := mailConsoleRouter(t, nil, nil, d) + + form := "_csrf=" + csrf.Token("ops") + "&scope=all_running&recipients=active&body=hello" + rec := consolePost(t, router, "/_gm/broadcast", form) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "5 recipients") { + t.Errorf("broadcast result missing recipient count; body=%s", rec.Body.String()) + } + if d.broadcastCalls != 1 || d.last.Scope != "all_running" || d.last.Body != "hello" || d.last.CallerUsername != "ops" { + t.Errorf("broadcast input = %+v (calls=%d)", d.last, d.broadcastCalls) + } +} + +func TestConsoleBroadcastSendBadGameIDs(t *testing.T) { + d := &fakeDiplomailAdmin{} + router, csrf := mailConsoleRouter(t, nil, nil, d) + + form := "_csrf=" + csrf.Token("ops") + "&scope=selected&game_ids=not-a-uuid&body=hello" + rec := consolePost(t, router, "/_gm/broadcast", form) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if d.broadcastCalls != 0 { + t.Error("broadcast must not run with invalid game ids") + } +} + +func TestConsoleMailUnavailable(t *testing.T) { + router, _ := mailConsoleRouter(t, nil, nil, nil) + + rec := consoleGet(t, router, "/_gm/mail") + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", rec.Code) + } +} diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 4e5c3e5..5a44de0 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -414,6 +414,13 @@ func registerAdminConsoleRoutes(router *gin.Engine, deps RouterDependencies) { group.POST("/operators/:username/disable", deps.AdminConsole.OperatorDisable()) group.POST("/operators/:username/enable", deps.AdminConsole.OperatorEnable()) group.POST("/operators/:username/reset-password", deps.AdminConsole.OperatorResetPassword()) + + group.GET("/mail", deps.AdminConsole.MailPage()) + group.GET("/mail/deliveries/:delivery_id", deps.AdminConsole.MailDeliveryDetail()) + group.POST("/mail/deliveries/:delivery_id/resend", deps.AdminConsole.MailResend()) + group.GET("/notifications", deps.AdminConsole.NotificationsPage()) + group.GET("/broadcast", deps.AdminConsole.BroadcastForm()) + group.POST("/broadcast", deps.AdminConsole.BroadcastSend()) } // allowedMethodsForPath returns the comma-separated list of methods diff --git a/integration/admin_console_test.go b/integration/admin_console_test.go new file mode 100644 index 0000000..2f378ff --- /dev/null +++ b/integration/admin_console_test.go @@ -0,0 +1,63 @@ +package integration_test + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + "time" + + "galaxy/integration/testenv" +) + +// TestAdminConsole_ThroughGateway verifies the full operator-console pipe: +// the edge gateway reverse-proxies `/_gm/*` to the backend, the backend +// enforces the admin Basic Auth and relays its 401 challenge unchanged, and an +// authenticated request renders the server-side dashboard. +func TestAdminConsole_ThroughGateway(t *testing.T) { + plat := testenv.Bootstrap(t, testenv.BootstrapOptions{}) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + base := plat.Gateway.HTTPURL + client := &http.Client{Timeout: 10 * time.Second} + + // Unauthenticated: the backend's 401 + Basic challenge must reach the client + // through the gateway. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/_gm/", nil) + if err != nil { + t.Fatalf("build request: %v", err) + } + resp, err := client.Do(req) + if err != nil { + t.Fatalf("GET /_gm/ (no auth): %v", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("no-auth status = %d, want 401; body=%s", resp.StatusCode, body) + } + if !strings.Contains(resp.Header.Get("WWW-Authenticate"), "Basic") { + t.Fatalf("WWW-Authenticate = %q, want a Basic challenge", resp.Header.Get("WWW-Authenticate")) + } + + // Authenticated with the bootstrap operator: the dashboard renders. + req, err = http.NewRequestWithContext(ctx, http.MethodGet, base+"/_gm/", nil) + if err != nil { + t.Fatalf("build request: %v", err) + } + req.SetBasicAuth(plat.Backend.AdminUser, plat.Backend.AdminPassword) + resp, err = client.Do(req) + if err != nil { + t.Fatalf("GET /_gm/ (auth): %v", err) + } + body, _ = io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("authenticated status = %d, want 200; body=%s", resp.StatusCode, body) + } + if !strings.Contains(string(body), "Dashboard") { + t.Fatalf("dashboard body missing the heading; got: %s", body) + } +}