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"