feat(admin-console): Stage 1 — pipe + skeleton behind the gateway
Tests · Go / test (push) Successful in 2m0s

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.
This commit is contained in:
Ilia Denisov
2026-05-31 19:50:15 +02:00
parent 5d2f2bfc26
commit 27916bbe61
28 changed files with 1319 additions and 3 deletions
+8 -1
View File
@@ -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`. |
+15
View File
@@ -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)
+90
View File
@@ -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. |
@@ -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; }
+54
View File
@@ -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))
}
@@ -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")
}
}
+18
View File
@@ -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
+107
View File
@@ -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")
}
@@ -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{
"<!DOCTYPE html>",
"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: "<script>evil</script>"}); err != nil {
t.Fatalf("Render: %v", err)
}
if strings.Contains(buf.String(), "<script>evil</script>") {
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)
}
}
@@ -0,0 +1,28 @@
{{define "layout" -}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{{.Title}} · Galaxy GM</title>
<link rel="stylesheet" href="/_gm/assets/console.css">
</head>
<body>
<header class="topbar">
<span class="brand">Galaxy · GM</span>
<nav class="mainnav">
<a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</a>
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
<a href="/_gm/operators"{{if eq .ActiveNav "operators"}} class="active"{{end}}>Operators</a>
<a href="/_gm/mail"{{if eq .ActiveNav "mail"}} class="active"{{end}}>Mail</a>
</nav>
<span class="who">{{.Username}}</span>
</header>
<main class="content">
{{template "content" .}}
</main>
</body>
</html>
{{- end}}
@@ -0,0 +1,22 @@
{{define "content" -}}
<h1>Dashboard</h1>
<p class="lede">Signed in as <strong>{{.Username}}</strong>.</p>
<section class="cards">
<a class="card" href="/_gm/users">
<h2>Users</h2>
<p>Accounts, sanctions, entitlements, soft-delete.</p>
</a>
<a class="card" href="/_gm/games">
<h2>Games &amp; runtimes</h2>
<p>Lobby state, engine versions, turn control.</p>
</a>
<a class="card" href="/_gm/operators">
<h2>Operators</h2>
<p>Admin accounts: create, disable, reset password.</p>
</a>
<a class="card" href="/_gm/mail">
<h2>Mail &amp; notifications</h2>
<p>Deliveries, dead-letters, broadcasts.</p>
</a>
</section>
{{- end}}
+14
View File
@@ -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))
@@ -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)
}
@@ -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())
}
})
}
}
+26
View File
@@ -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
+26 -1
View File
@@ -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
+16
View File
@@ -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
+17
View File
@@ -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-аккаунтами
Существующие админы могут перечислять других админов, создавать
+24
View File
@@ -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.
+9
View File
@@ -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()
+52
View File
@@ -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
}
+49
View File
@@ -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
}
@@ -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("<h1>Dashboard</h1>"))
}))
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)
}
@@ -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":
+21
View File
@@ -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 != "" {
+30
View File
@@ -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"),
+8
View File
@@ -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 {
+19 -1
View File
@@ -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"