feat(admin-console): server-rendered operator console at /_gm #87
+8
-1
@@ -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`. |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 & 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 & notifications</h2>
|
||||
<p>Deliveries, dead-letters, broadcasts.</p>
|
||||
</a>
|
||||
</section>
|
||||
{{- end}}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-аккаунтами
|
||||
|
||||
Существующие админы могут перечислять других админов, создавать
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user