feat(admin-console): Stage 1 — pipe + skeleton behind the gateway
Tests · Go / test (push) Successful in 2m0s
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:
@@ -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}}
|
||||
Reference in New Issue
Block a user