Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a19512adaa | |||
| 814eae0802 | |||
| cb8491c200 | |||
| 45815c27d9 | |||
| e11092234c | |||
| 84a0ccb23f | |||
| 7fb6a63c2b | |||
| 225f89fad6 | |||
| 0cae89cba2 | |||
| 26f1e62924 | |||
| 7cac910de4 | |||
| 87a272166b | |||
| ecfb2d3351 | |||
| cf34710b4f | |||
| 985e51d25e | |||
| 27916bbe61 |
@@ -148,12 +148,37 @@ jobs:
|
|||||||
-v "${{ gitea.workspace }}/pkg/geoip/test-data/test-data:/src:ro" \
|
-v "${{ gitea.workspace }}/pkg/geoip/test-data/test-data:/src:ro" \
|
||||||
alpine sh -c 'cp /src/GeoIP2-Country-Test.mmdb /dst/geoip.mmdb'
|
alpine sh -c 'cp /src/GeoIP2-Country-Test.mmdb /dst/geoip.mmdb'
|
||||||
|
|
||||||
|
- name: Seed mailpit relay config
|
||||||
|
env:
|
||||||
|
GALAXY_DEV_MAIL_RELAY_USERNAME: ${{ secrets.GALAXY_DEV_MAIL_RELAY_USERNAME }}
|
||||||
|
GALAXY_DEV_MAIL_RELAY_PASSWORD: ${{ secrets.GALAXY_DEV_MAIL_RELAY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
# Render the Mailpit relay upstream config from the template,
|
||||||
|
# substituting the Gmail App Password from a Gitea secret, then
|
||||||
|
# seed it into a named volume (same rationale as the geoip seed:
|
||||||
|
# a workspace bind-mount would vanish with the runner workspace).
|
||||||
|
# The secret never lands in git or a committed file; it is
|
||||||
|
# rendered to a tmpfile outside the repo and removed after. Gmail
|
||||||
|
# App Passwords are [a-z]{16}, so the `|` sed delimiter is safe.
|
||||||
|
# When the secret is unset the creds render empty and the compose
|
||||||
|
# default relay-match is non-routable, so the stack only captures.
|
||||||
|
rendered="$(mktemp)"
|
||||||
|
sed -e "s|\${GALAXY_DEV_MAIL_RELAY_USERNAME}|${GALAXY_DEV_MAIL_RELAY_USERNAME}|g" \
|
||||||
|
-e "s|\${GALAXY_DEV_MAIL_RELAY_PASSWORD}|${GALAXY_DEV_MAIL_RELAY_PASSWORD}|g" \
|
||||||
|
"${{ gitea.workspace }}/tools/dev-deploy/mailpit/relay.conf.tmpl" > "$rendered"
|
||||||
|
docker volume create galaxy-dev-mailpit-config >/dev/null
|
||||||
|
docker run --rm \
|
||||||
|
-v galaxy-dev-mailpit-config:/dst \
|
||||||
|
-v "$rendered:/src/relay.conf:ro" \
|
||||||
|
alpine sh -c 'cp /src/relay.conf /dst/relay.conf && chmod 600 /dst/relay.conf'
|
||||||
|
rm -f "$rendered"
|
||||||
|
|
||||||
- name: Recycle engine containers on image drift
|
- name: Recycle engine containers on image drift
|
||||||
run: |
|
run: |
|
||||||
# Compare the freshly-built `galaxy-engine:dev` SHA against
|
# Compare the freshly-built `galaxy-engine:dev` SHA against
|
||||||
# every running `galaxy-game-*` container. The backend
|
# every running `galaxy-game-*` container. The backend
|
||||||
# reconciler adopts pre-existing labelled engine containers
|
# reconciler adopts pre-existing labelled engine containers
|
||||||
# without checking image drift, so a running sandbox would
|
# without checking image drift, so a running game would
|
||||||
# otherwise keep serving the previous engine code until the
|
# otherwise keep serving the previous engine code until the
|
||||||
# container is recycled by hand. This step makes the recycle
|
# container is recycled by hand. This step makes the recycle
|
||||||
# automatic but only when it is actually needed:
|
# automatic but only when it is actually needed:
|
||||||
@@ -168,10 +193,7 @@ jobs:
|
|||||||
# silent state corruption otherwise), and cascade-delete
|
# silent state corruption otherwise), and cascade-delete
|
||||||
# the lobby `games` row (the FKs in `00001_init.sql`
|
# the lobby `games` row (the FKs in `00001_init.sql`
|
||||||
# drop the matching `runtime_records`, `memberships`,
|
# drop the matching `runtime_records`, `memberships`,
|
||||||
# `player_mappings`, etc. in the same write). The
|
# `player_mappings`, etc. in the same write).
|
||||||
# `dev-sandbox` bootstrap on the next backend boot finds
|
|
||||||
# no live sandbox and provisions a fresh one on the new
|
|
||||||
# engine image.
|
|
||||||
#
|
#
|
||||||
# Backend is stopped first to keep the reconciler from
|
# Backend is stopped first to keep the reconciler from
|
||||||
# racing the recycle (mid-stream adoption / restart). The
|
# racing the recycle (mid-stream adoption / restart). The
|
||||||
@@ -234,11 +256,24 @@ jobs:
|
|||||||
|
|
||||||
- name: Bring up the stack
|
- name: Bring up the stack
|
||||||
working-directory: tools/dev-deploy
|
working-directory: tools/dev-deploy
|
||||||
|
env:
|
||||||
|
# Recipient regex Mailpit auto-relays to the owner's Gmail.
|
||||||
|
# Unset/empty → the compose default (non-routable) keeps the
|
||||||
|
# stack capture-only.
|
||||||
|
GALAXY_DEV_MAIL_RELAY_MATCH: ${{ vars.GALAXY_DEV_MAIL_RELAY_MATCH }}
|
||||||
|
# Grafana admin password; unset/empty -> compose default 'admin'.
|
||||||
|
GALAXY_DEV_GRAFANA_ADMIN_PASSWORD: ${{ secrets.GALAXY_DEV_GRAFANA_ADMIN_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
# Resolve in the shell, not in YAML expressions — `env.HOME`
|
# Resolve in the shell, not in YAML expressions — `env.HOME`
|
||||||
# is empty at the workflow-evaluation stage.
|
# is empty at the workflow-evaluation stage.
|
||||||
export GALAXY_DEV_GAME_STATE_DIR="$HOME/.galaxy-dev/game-state"
|
export GALAXY_DEV_GAME_STATE_DIR="$HOME/.galaxy-dev/game-state"
|
||||||
mkdir -p "$GALAXY_DEV_GAME_STATE_DIR"
|
mkdir -p "$GALAXY_DEV_GAME_STATE_DIR"
|
||||||
|
# Seed the monitoring config to a stable, reboot-surviving host
|
||||||
|
# path (compose binds \${GALAXY_DEV_MONITORING_DIR} read-only).
|
||||||
|
export GALAXY_DEV_MONITORING_DIR="$HOME/.galaxy-dev/monitoring"
|
||||||
|
rm -rf "$GALAXY_DEV_MONITORING_DIR"
|
||||||
|
mkdir -p "$GALAXY_DEV_MONITORING_DIR"
|
||||||
|
cp -r monitoring/. "$GALAXY_DEV_MONITORING_DIR/"
|
||||||
docker compose up -d --wait --remove-orphans
|
docker compose up -d --wait --remove-orphans
|
||||||
|
|
||||||
- name: Probe the stack
|
- name: Probe the stack
|
||||||
|
|||||||
+8
-1
@@ -27,10 +27,16 @@ The implementation specification lives in `PLAN.md`.
|
|||||||
| ------------------ | ----------------------------------------------- | ------------------------------------- |
|
| ------------------ | ----------------------------------------------- | ------------------------------------- |
|
||||||
| `/api/v1/public/*` | none | Registration, code confirmation |
|
| `/api/v1/public/*` | none | Registration, code confirmation |
|
||||||
| `/api/v1/user/*` | `X-User-ID` injected by gateway | Authenticated end users |
|
| `/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 |
|
| `/healthz` | none | Liveness probe |
|
||||||
| `/readyz` | none | Readiness 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
|
The full contract is documented in `openapi.yaml` and validated at
|
||||||
runtime by the contract tests under `internal/server/`.
|
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_GAME_STATE_ROOT` | yes | — | Host directory bind-mounted into engine containers. |
|
||||||
| `BACKEND_ADMIN_BOOTSTRAP_USER` | no | — | Initial admin username; idempotent insert. |
|
| `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_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_GEOIP_DB_PATH` | yes | — | Filesystem path to GeoLite2 Country `.mmdb`. |
|
||||||
| `BACKEND_OTEL_TRACES_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`. |
|
| `BACKEND_OTEL_TRACES_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`. |
|
||||||
| `BACKEND_OTEL_METRICS_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`, `prometheus`. |
|
| `BACKEND_OTEL_METRICS_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`, `prometheus`. |
|
||||||
|
|||||||
+35
-18
@@ -22,10 +22,10 @@ import (
|
|||||||
_ "time/tzdata"
|
_ "time/tzdata"
|
||||||
|
|
||||||
"galaxy/backend/internal/admin"
|
"galaxy/backend/internal/admin"
|
||||||
|
"galaxy/backend/internal/adminconsole"
|
||||||
"galaxy/backend/internal/app"
|
"galaxy/backend/internal/app"
|
||||||
"galaxy/backend/internal/auth"
|
"galaxy/backend/internal/auth"
|
||||||
"galaxy/backend/internal/config"
|
"galaxy/backend/internal/config"
|
||||||
"galaxy/backend/internal/devsandbox"
|
|
||||||
"galaxy/backend/internal/diplomail"
|
"galaxy/backend/internal/diplomail"
|
||||||
"galaxy/backend/internal/diplomail/detector"
|
"galaxy/backend/internal/diplomail/detector"
|
||||||
"galaxy/backend/internal/diplomail/translator"
|
"galaxy/backend/internal/diplomail/translator"
|
||||||
@@ -37,6 +37,7 @@ import (
|
|||||||
"galaxy/backend/internal/mail"
|
"galaxy/backend/internal/mail"
|
||||||
"galaxy/backend/internal/metricsapi"
|
"galaxy/backend/internal/metricsapi"
|
||||||
"galaxy/backend/internal/notification"
|
"galaxy/backend/internal/notification"
|
||||||
|
"galaxy/backend/internal/opsstatus"
|
||||||
backendpostgres "galaxy/backend/internal/postgres"
|
backendpostgres "galaxy/backend/internal/postgres"
|
||||||
"galaxy/backend/push"
|
"galaxy/backend/push"
|
||||||
"galaxy/backend/internal/runtime"
|
"galaxy/backend/internal/runtime"
|
||||||
@@ -272,29 +273,18 @@ func run(ctx context.Context) (err error) {
|
|||||||
)
|
)
|
||||||
runtimeGateway.svc = runtimeSvc
|
runtimeGateway.svc = runtimeSvc
|
||||||
|
|
||||||
// Run a single reconciliation pass before the dev-sandbox
|
// Run a single reconciliation pass at startup so any runtime row
|
||||||
// bootstrap so any runtime row pointing at a vanished engine
|
// pointing at a vanished engine container (a host reboot wiped
|
||||||
// container (host reboot wiped /tmp/galaxy-game-state/<uuid>;
|
// /tmp/galaxy-game-state/<uuid>; `tools/local-dev`'s
|
||||||
// `tools/local-dev`'s `prune-broken-engines` target reaped the
|
// `prune-broken-engines` target reaped the husk) is cascaded
|
||||||
// husk) is already cascaded through `markRemoved` → lobby
|
// through `markRemoved` → lobby `cancelled` before the server
|
||||||
// `cancelled` by the time the bootstrap walks the sandbox list.
|
// starts serving requests. Failures are
|
||||||
// Without this pre-tick the bootstrap would reuse the
|
|
||||||
// soon-to-be-cancelled game and force the developer into a
|
|
||||||
// second `make up` cycle to land a healthy sandbox. Failures are
|
|
||||||
// non-fatal: the periodic ticker started later catches up, and
|
// non-fatal: the periodic ticker started later catches up, and
|
||||||
// the worst case degrades to the legacy two-cycle recovery.
|
// the worst case degrades to the legacy two-cycle recovery.
|
||||||
if err := runtimeSvc.Reconciler().Tick(ctx); err != nil {
|
if err := runtimeSvc.Reconciler().Tick(ctx); err != nil {
|
||||||
logger.Warn("pre-bootstrap reconciler tick failed", zap.Error(err))
|
logger.Warn("pre-bootstrap reconciler tick failed", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := devsandbox.Bootstrap(ctx, devsandbox.Deps{
|
|
||||||
Users: userSvc,
|
|
||||||
Lobby: lobbySvc,
|
|
||||||
EngineVersions: engineVersionSvc,
|
|
||||||
}, cfg.DevSandbox, logger); err != nil {
|
|
||||||
return fmt.Errorf("dev sandbox bootstrap: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
notifStore := notification.NewStore(db)
|
notifStore := notification.NewStore(db)
|
||||||
notifSvc := notification.NewService(notification.Deps{
|
notifSvc := notification.NewService(notification.Deps{
|
||||||
Store: notifStore,
|
Store: notifStore,
|
||||||
@@ -360,6 +350,32 @@ func run(ctx context.Context) (err error) {
|
|||||||
return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready()
|
return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var consoleCSRF *adminconsole.CSRF
|
||||||
|
if cfg.AdminConsole.CSRFKey != "" {
|
||||||
|
consoleCSRF = adminconsole.NewCSRF([]byte(cfg.AdminConsole.CSRFKey))
|
||||||
|
} 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(backendserver.AdminConsoleDeps{
|
||||||
|
CSRF: consoleCSRF,
|
||||||
|
Monitor: opsstatus.NewStore(db),
|
||||||
|
Ready: ready,
|
||||||
|
Users: userSvc,
|
||||||
|
Games: lobbySvc,
|
||||||
|
Runtime: runtimeSvc,
|
||||||
|
EngineVersions: engineVersionSvc,
|
||||||
|
Operators: adminSvc,
|
||||||
|
Mail: mailSvc,
|
||||||
|
Notifications: notifSvc,
|
||||||
|
Diplomail: diplomailSvc,
|
||||||
|
Logger: logger,
|
||||||
|
})
|
||||||
|
|
||||||
handler, err := backendserver.NewRouter(backendserver.RouterDependencies{
|
handler, err := backendserver.NewRouter(backendserver.RouterDependencies{
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
Telemetry: telemetryRT,
|
Telemetry: telemetryRT,
|
||||||
@@ -388,6 +404,7 @@ func run(ctx context.Context) (err error) {
|
|||||||
AdminGeo: adminGeoHandlers,
|
AdminGeo: adminGeoHandlers,
|
||||||
UserGames: userGamesHandlers,
|
UserGames: userGamesHandlers,
|
||||||
UserMail: userMailHandlers,
|
UserMail: userMailHandlers,
|
||||||
|
AdminConsole: adminConsoleHandlers,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("build backend router: %w", err)
|
return fmt.Errorf("build backend router: %w", err)
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
| Path | Method | Purpose |
|
||||||
|
| --------------------------------- | -------- | -------------------------------------------------------------- |
|
||||||
|
| `/_gm`, `/_gm/` | GET | Dashboard: health, runtime/mail/notification status, queues. |
|
||||||
|
| `/_gm/assets/*` | GET | Embedded stylesheet. |
|
||||||
|
| `/_gm/users` | GET | Paginated account list. |
|
||||||
|
| `/_gm/users/{id}` | GET | Account detail: profile, entitlement, active sanctions. |
|
||||||
|
| `/_gm/users/{id}/block` | POST | Apply a permanent block (reason required). |
|
||||||
|
| `/_gm/users/{id}/entitlement` | POST | Set the entitlement tier. |
|
||||||
|
| `/_gm/users/{id}/soft-delete` | POST | Soft-delete the account (cascades). |
|
||||||
|
| `/_gm/games` | GET/POST | Paginated game list; POST creates a public game. |
|
||||||
|
| `/_gm/games/{id}` | GET | Game detail with the runtime snapshot. |
|
||||||
|
| `/_gm/games/{id}/force-start` | POST | Force-start the game. |
|
||||||
|
| `/_gm/games/{id}/force-stop` | POST | Force-stop the game. |
|
||||||
|
| `/_gm/games/{id}/ban-member` | POST | Ban a member (user id + reason). |
|
||||||
|
| `/_gm/games/{id}/runtime/restart` | POST | Restart the engine container. |
|
||||||
|
| `/_gm/games/{id}/runtime/patch` | POST | Patch the runtime to a target version. |
|
||||||
|
| `/_gm/games/{id}/runtime/force-next-turn` | POST | Force the next turn now. |
|
||||||
|
| `/_gm/engine-versions` | GET/POST | Version registry; POST registers a version. |
|
||||||
|
| `/_gm/engine-versions/{ver}/disable` | POST | Disable a registered version. |
|
||||||
|
| `/_gm/operators` | GET/POST | Admin-account list; POST creates an operator. |
|
||||||
|
| `/_gm/operators/{user}/disable` | POST | Disable an operator. |
|
||||||
|
| `/_gm/operators/{user}/enable` | POST | Re-enable an operator. |
|
||||||
|
| `/_gm/operators/{user}/reset-password` | POST | Reset an operator's password. |
|
||||||
|
| `/_gm/mail` | GET | Mail deliveries (paginated) + a dead-letter snapshot. |
|
||||||
|
| `/_gm/mail/deliveries/{id}` | GET | Delivery detail with its attempts. |
|
||||||
|
| `/_gm/mail/deliveries/{id}/resend`| POST | Re-enqueue a non-sent delivery. |
|
||||||
|
| `/_gm/notifications` | GET | Notifications, dead-letters, and malformed intents overview. |
|
||||||
|
| `/_gm/broadcast` | GET/POST | Admin multi-game diplomatic broadcast. |
|
||||||
|
|
||||||
|
Each page reuses the same service layer as the corresponding `/api/v1/admin/*`
|
||||||
|
JSON endpoint; the console adds no business logic. Collection-mutating POSTs are
|
||||||
|
mounted on the collection path (`POST /_gm/games`, `POST /_gm/engine-versions`)
|
||||||
|
so a static action segment never collides with a path parameter in the gin
|
||||||
|
router. Unblocking a user is not yet available because the JSON admin API
|
||||||
|
exposes no remove-sanction endpoint.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| 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,103 @@
|
|||||||
|
/* 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; }
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 0.9rem 1.1rem;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.panel h2 { font-size: 1rem; margin: 0 0 0.6rem; color: var(--ink); }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 1rem; }
|
||||||
|
.grid .panel { margin-bottom: 0; }
|
||||||
|
.kv { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.kv li { padding: 0.15rem 0; color: var(--ink-dim); }
|
||||||
|
.counts { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||||
|
.counts td { padding: 0.2rem 0; border-bottom: 1px solid var(--line); color: var(--ink-dim); }
|
||||||
|
.counts td.num { text-align: right; color: var(--ink); font-variant-numeric: tabular-nums; }
|
||||||
|
.bignum { font-size: 1.6rem; margin: 0; color: var(--ink); }
|
||||||
|
.note { color: var(--ink-dim); font-style: italic; margin: 0.2rem 0; }
|
||||||
|
.errors { border-color: var(--danger); }
|
||||||
|
.errors ul { margin: 0; padding-left: 1.1rem; color: var(--danger); }
|
||||||
|
.ok { color: var(--ok); }
|
||||||
|
.bad { color: var(--danger); }
|
||||||
|
|
||||||
|
.list { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||||
|
.list th, .list td { text-align: left; padding: 0.35rem 0.6rem; border-bottom: 1px solid var(--line); }
|
||||||
|
.list th { color: var(--ink-dim); font-weight: 600; }
|
||||||
|
.list tr:hover td { background: var(--panel-hi); }
|
||||||
|
.pager { display: flex; gap: 1rem; align-items: center; color: var(--ink-dim); }
|
||||||
|
.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.8rem; }
|
||||||
|
.form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); }
|
||||||
|
.form input, .form select {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #06121f;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover { filter: brightness(1.1); }
|
||||||
|
button.danger { background: var(--danger); color: #1a0606; }
|
||||||
|
code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
|
||||||
|
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
|
||||||
|
.actions form { margin: 0; }
|
||||||
|
.subnav { color: var(--ink-dim); margin: -0.3rem 0 1rem; font-size: 0.9rem; }
|
||||||
@@ -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,24 @@
|
|||||||
|
package adminconsole
|
||||||
|
|
||||||
|
// StatusCount pairs a status label with its current row count for the
|
||||||
|
// dashboard's per-status tables. It is the view-layer counterpart of the
|
||||||
|
// data gathered by the ops-status reader; the server handler maps between
|
||||||
|
// them so this package stays free of database concerns.
|
||||||
|
type StatusCount struct {
|
||||||
|
Status string
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardData is the view model for the console landing page. MonitorAvailable
|
||||||
|
// is false when no ops-status reader is wired, in which case the monitoring
|
||||||
|
// panels are omitted. Errors carries non-fatal probe failures for display.
|
||||||
|
type DashboardData struct {
|
||||||
|
MonitorAvailable bool
|
||||||
|
BackendReady bool
|
||||||
|
PostgresHealthy bool
|
||||||
|
Runtimes []StatusCount
|
||||||
|
MailDeliveries []StatusCount
|
||||||
|
NotificationRoutes []StatusCount
|
||||||
|
NotificationMalformed int64
|
||||||
|
Errors []string
|
||||||
|
}
|
||||||
@@ -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,67 @@
|
|||||||
|
package adminconsole
|
||||||
|
|
||||||
|
// GameRow is one line in the games list table.
|
||||||
|
type GameRow struct {
|
||||||
|
GameID string
|
||||||
|
GameName string
|
||||||
|
Visibility string
|
||||||
|
Status string
|
||||||
|
Owner string
|
||||||
|
Players string
|
||||||
|
TurnSchedule string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GamesListData is the view model for the paginated games list.
|
||||||
|
type GamesListData struct {
|
||||||
|
Items []GameRow
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
Total int
|
||||||
|
HasPrev bool
|
||||||
|
HasNext bool
|
||||||
|
PrevPage int
|
||||||
|
NextPage int
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameDetailData is the view model for a single game, combining the lobby
|
||||||
|
// record with the runtime snapshot and the available actions.
|
||||||
|
type GameDetailData struct {
|
||||||
|
GameID string
|
||||||
|
GameName string
|
||||||
|
Description string
|
||||||
|
Visibility string
|
||||||
|
Status string
|
||||||
|
Owner string
|
||||||
|
MinPlayers int32
|
||||||
|
MaxPlayers int32
|
||||||
|
StartGapHours int32
|
||||||
|
StartGapPlayers int32
|
||||||
|
TurnSchedule string
|
||||||
|
TargetEngineVersion string
|
||||||
|
EnrollmentEndsAt string
|
||||||
|
CreatedAt string
|
||||||
|
StartedAt string
|
||||||
|
FinishedAt string
|
||||||
|
|
||||||
|
HasRuntime bool
|
||||||
|
RuntimeStatus string
|
||||||
|
CurrentEngineVersion string
|
||||||
|
EngineHealth string
|
||||||
|
CurrentTurn int32
|
||||||
|
NextGenerationAt string
|
||||||
|
Paused bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngineVersionRow is one line in the engine-version registry table.
|
||||||
|
type EngineVersionRow struct {
|
||||||
|
Version string
|
||||||
|
ImageRef string
|
||||||
|
Enabled bool
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngineVersionsData is the view model for the engine-version registry page.
|
||||||
|
type EngineVersionsData struct {
|
||||||
|
Items []EngineVersionRow
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package adminconsole
|
||||||
|
|
||||||
|
// MailDeliveryRow is one line in the mail deliveries table.
|
||||||
|
type MailDeliveryRow struct {
|
||||||
|
DeliveryID string
|
||||||
|
Template string
|
||||||
|
Status string
|
||||||
|
Attempts int32
|
||||||
|
NextAttempt string
|
||||||
|
Created string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailDeadLetterRow is one line in the mail dead-letters table.
|
||||||
|
type MailDeadLetterRow struct {
|
||||||
|
DeliveryID string
|
||||||
|
Reason string
|
||||||
|
Archived string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailData is the view model for the mail page: a paginated deliveries list
|
||||||
|
// plus a snapshot of dead-letters.
|
||||||
|
type MailData struct {
|
||||||
|
Deliveries []MailDeliveryRow
|
||||||
|
DeadLetters []MailDeadLetterRow
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
Total int64
|
||||||
|
HasPrev bool
|
||||||
|
HasNext bool
|
||||||
|
PrevPage int
|
||||||
|
NextPage int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailAttemptRow is one delivery attempt on the mail detail page.
|
||||||
|
type MailAttemptRow struct {
|
||||||
|
AttemptNo int32
|
||||||
|
Outcome string
|
||||||
|
Started string
|
||||||
|
Finished string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailDeliveryDetail is the view model for a single delivery.
|
||||||
|
type MailDeliveryDetail struct {
|
||||||
|
DeliveryID string
|
||||||
|
Template string
|
||||||
|
Status string
|
||||||
|
Attempts int32
|
||||||
|
NextAttempt string
|
||||||
|
LastError string
|
||||||
|
Created string
|
||||||
|
Sent string
|
||||||
|
DeadLettered string
|
||||||
|
CanResend bool
|
||||||
|
AttemptRows []MailAttemptRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationRow is one line in the notifications table.
|
||||||
|
type NotificationRow struct {
|
||||||
|
NotificationID string
|
||||||
|
Kind string
|
||||||
|
UserID string
|
||||||
|
Created string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationDeadLetterRow is one line in the notification dead-letters table.
|
||||||
|
type NotificationDeadLetterRow struct {
|
||||||
|
NotificationID string
|
||||||
|
RouteID string
|
||||||
|
Reason string
|
||||||
|
Archived string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MalformedRow is one line in the malformed-intents table.
|
||||||
|
type MalformedRow struct {
|
||||||
|
ID string
|
||||||
|
Reason string
|
||||||
|
Received string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationsData is the view model for the notifications overview page.
|
||||||
|
type NotificationsData struct {
|
||||||
|
Notifications []NotificationRow
|
||||||
|
DeadLetters []NotificationDeadLetterRow
|
||||||
|
Malformed []MalformedRow
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package adminconsole
|
||||||
|
|
||||||
|
// MessageData is the view model for the generic message page used to render
|
||||||
|
// not-found, validation, and operation-failure notices. Class selects the CSS
|
||||||
|
// styling (for example "bad" for errors); BackHref, when set, renders a link
|
||||||
|
// back to a relevant page.
|
||||||
|
type MessageData struct {
|
||||||
|
Message string
|
||||||
|
Class string
|
||||||
|
BackHref string
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package adminconsole
|
||||||
|
|
||||||
|
// OperatorRow is one line in the operators (admin accounts) table.
|
||||||
|
type OperatorRow struct {
|
||||||
|
Username string
|
||||||
|
CreatedAt string
|
||||||
|
LastUsedAt string
|
||||||
|
Disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// OperatorsData is the view model for the operators page.
|
||||||
|
type OperatorsData struct {
|
||||||
|
Items []OperatorRow
|
||||||
|
}
|
||||||
@@ -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,30 @@
|
|||||||
|
{{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>
|
||||||
|
<a href="/_gm/grafana/" target="_blank" rel="noopener">Grafana</a>
|
||||||
|
<a href="/_gm/mailpit/" target="_blank" rel="noopener">Mailpit</a>
|
||||||
|
</nav>
|
||||||
|
<span class="who">{{.Username}}</span>
|
||||||
|
</header>
|
||||||
|
<main class="content">
|
||||||
|
{{template "content" .}}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{$csrf := .CSRFToken}}
|
||||||
|
<h1>Broadcast</h1>
|
||||||
|
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Admin broadcast</h2>
|
||||||
|
<form method="post" action="/_gm/broadcast" class="form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||||
|
<label>Scope
|
||||||
|
<select name="scope"><option value="all_running">all running games</option><option value="selected">selected games</option></select>
|
||||||
|
</label>
|
||||||
|
<label>Game IDs (comma-separated, for "selected") <input type="text" name="game_ids" placeholder="uuid,uuid"></label>
|
||||||
|
<label>Recipients
|
||||||
|
<select name="recipients"><option value="active">active members</option><option value="active_and_removed">active and removed</option><option value="all_members">all members</option></select>
|
||||||
|
</label>
|
||||||
|
<label>Subject <input type="text" name="subject"></label>
|
||||||
|
<label>Body <input type="text" name="body" required></label>
|
||||||
|
<button type="submit">Send broadcast</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<p class="lede">Signed in as <strong>{{.Username}}</strong>.</p>
|
||||||
|
{{with .Data}}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Health</h2>
|
||||||
|
<ul class="kv">
|
||||||
|
<li>Backend ready: {{if .BackendReady}}<span class="ok">yes</span>{{else}}<span class="bad">no</span>{{end}}</li>
|
||||||
|
<li>Postgres: {{if .PostgresHealthy}}<span class="ok">healthy</span>{{else}}<span class="bad">unreachable</span>{{end}}</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{{if .MonitorAvailable}}
|
||||||
|
<div class="grid">
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Game runtimes</h2>
|
||||||
|
{{template "statuscounts" .Runtimes}}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Mail deliveries</h2>
|
||||||
|
{{template "statuscounts" .MailDeliveries}}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Notification routes</h2>
|
||||||
|
{{template "statuscounts" .NotificationRoutes}}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Malformed notifications</h2>
|
||||||
|
<p class="bignum {{if gt .NotificationMalformed 0}}bad{{end}}">{{.NotificationMalformed}}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{{if .Errors}}
|
||||||
|
<section class="panel errors">
|
||||||
|
<h2>Collection errors</h2>
|
||||||
|
<ul>{{range .Errors}}<li>{{.}}</li>{{end}}</ul>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p class="note">Monitoring is not wired in this deployment.</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
<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}}
|
||||||
|
|
||||||
|
{{define "statuscounts" -}}
|
||||||
|
{{if .}}
|
||||||
|
<table class="counts"><tbody>
|
||||||
|
{{range .}}<tr><td>{{.Status}}</td><td class="num">{{.Count}}</td></tr>{{end}}
|
||||||
|
</tbody></table>
|
||||||
|
{{else}}
|
||||||
|
<p class="note">none</p>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{$csrf := .CSRFToken}}
|
||||||
|
<h1>Engine versions</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Version</th><th>Image</th><th>Enabled</th><th>Created</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Items}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Version}}</td>
|
||||||
|
<td><code>{{.ImageRef}}</code></td>
|
||||||
|
<td>{{if .Enabled}}<span class="ok">yes</span>{{else}}<span class="bad">no</span>{{end}}</td>
|
||||||
|
<td>{{.CreatedAt}}</td>
|
||||||
|
<td>{{if .Enabled}}<form method="post" action="/_gm/engine-versions/{{.Version}}/disable" onsubmit="return confirm('Disable {{.Version}}?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Disable</button></form>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}<tr><td colspan="5"><span class="note">no engine versions</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Register version</h2>
|
||||||
|
<form method="post" action="/_gm/engine-versions" class="form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||||
|
<label>Version <input type="text" name="version" placeholder="semver e.g. 0.1.0" required></label>
|
||||||
|
<label>Image ref <input type="text" name="image_ref" required></label>
|
||||||
|
<label>Enabled <input type="checkbox" name="enabled" value="true" checked></label>
|
||||||
|
<button type="submit">Register</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{$csrf := .CSRFToken}}
|
||||||
|
{{with .Data}}
|
||||||
|
<p><a href="/_gm/games">« all games</a></p>
|
||||||
|
<h1>{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}</h1>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Game</h2>
|
||||||
|
<ul class="kv">
|
||||||
|
<li>Game ID: <code>{{.GameID}}</code></li>
|
||||||
|
<li>Visibility: {{.Visibility}}</li>
|
||||||
|
<li>Status: {{.Status}}</li>
|
||||||
|
<li>Owner: {{.Owner}}</li>
|
||||||
|
<li>Players: {{.MinPlayers}}–{{.MaxPlayers}}</li>
|
||||||
|
<li>Start gap: {{.StartGapHours}}h / {{.StartGapPlayers}} players</li>
|
||||||
|
<li>Turn schedule: {{.TurnSchedule}}</li>
|
||||||
|
<li>Target engine: {{.TargetEngineVersion}}</li>
|
||||||
|
<li>Enrollment ends: {{.EnrollmentEndsAt}}</li>
|
||||||
|
<li>Created: {{.CreatedAt}}</li>
|
||||||
|
<li>Started: {{if .StartedAt}}{{.StartedAt}}{{else}}—{{end}}</li>
|
||||||
|
<li>Finished: {{if .FinishedAt}}{{.FinishedAt}}{{else}}—{{end}}</li>
|
||||||
|
</ul>
|
||||||
|
{{if .Description}}<p>{{.Description}}</p>{{end}}
|
||||||
|
<div class="actions">
|
||||||
|
<form method="post" action="/_gm/games/{{.GameID}}/force-start" onsubmit="return confirm('Force-start this game?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Force start</button></form>
|
||||||
|
<form method="post" action="/_gm/games/{{.GameID}}/force-stop" onsubmit="return confirm('Force-stop this game?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Force stop</button></form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Runtime</h2>
|
||||||
|
{{if .HasRuntime}}
|
||||||
|
<ul class="kv">
|
||||||
|
<li>Status: {{.RuntimeStatus}}</li>
|
||||||
|
<li>Engine version: {{.CurrentEngineVersion}}</li>
|
||||||
|
<li>Engine health: {{.EngineHealth}}</li>
|
||||||
|
<li>Current turn: {{.CurrentTurn}}</li>
|
||||||
|
<li>Next generation: {{if .NextGenerationAt}}{{.NextGenerationAt}}{{else}}—{{end}}</li>
|
||||||
|
<li>Paused: {{if .Paused}}yes{{else}}no{{end}}</li>
|
||||||
|
</ul>
|
||||||
|
<div class="actions">
|
||||||
|
<form method="post" action="/_gm/games/{{.GameID}}/runtime/restart" onsubmit="return confirm('Restart the engine container?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Restart</button></form>
|
||||||
|
<form method="post" action="/_gm/games/{{.GameID}}/runtime/force-next-turn" onsubmit="return confirm('Force the next turn now?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Force next turn</button></form>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/_gm/games/{{.GameID}}/runtime/patch" class="form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||||
|
<label>Patch to version <input type="text" name="target_version" placeholder="e.g. 0.1.1" required></label>
|
||||||
|
<button type="submit">Patch</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<p class="note">No runtime record for this game yet.</p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Ban member</h2>
|
||||||
|
<form method="post" action="/_gm/games/{{.GameID}}/ban-member" class="form" onsubmit="return confirm('Ban this member from the game?');">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||||
|
<label>User ID <input type="text" name="user_id" required></label>
|
||||||
|
<label>Reason <input type="text" name="reason"></label>
|
||||||
|
<button type="submit" class="danger">Ban member</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{$csrf := .CSRFToken}}
|
||||||
|
<h1>Games</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Name</th><th>Visibility</th><th>Status</th><th>Owner</th><th>Players</th><th>Schedule</th><th>Created</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Items}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/_gm/games/{{.GameID}}">{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}</a></td>
|
||||||
|
<td>{{.Visibility}}</td>
|
||||||
|
<td>{{.Status}}</td>
|
||||||
|
<td>{{.Owner}}</td>
|
||||||
|
<td>{{.Players}}</td>
|
||||||
|
<td>{{.TurnSchedule}}</td>
|
||||||
|
<td>{{.CreatedAt}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}<tr><td colspan="7"><span class="note">no games</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<nav class="pager">
|
||||||
|
{{if .HasPrev}}<a href="/_gm/games?page={{.PrevPage}}&page_size={{.PageSize}}">« prev</a>{{end}}
|
||||||
|
<span>page {{.Page}} · {{.Total}} total</span>
|
||||||
|
{{if .HasNext}}<a href="/_gm/games?page={{.NextPage}}&page_size={{.PageSize}}">next »</a>{{end}}
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Create public game</h2>
|
||||||
|
<form method="post" action="/_gm/games" class="form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||||
|
<label>Name <input type="text" name="game_name" required></label>
|
||||||
|
<label>Description <input type="text" name="description"></label>
|
||||||
|
<label>Min players <input type="number" name="min_players" value="2" min="1"></label>
|
||||||
|
<label>Max players <input type="number" name="max_players" value="8" min="1"></label>
|
||||||
|
<label>Start gap hours <input type="number" name="start_gap_hours" value="0" min="0"></label>
|
||||||
|
<label>Start gap players <input type="number" name="start_gap_players" value="0" min="0"></label>
|
||||||
|
<label>Enrollment ends <input type="datetime-local" name="enrollment_ends_at" required></label>
|
||||||
|
<label>Turn schedule <input type="text" name="turn_schedule" placeholder="e.g. @every 24h" required></label>
|
||||||
|
<label>Engine version <input type="text" name="target_engine_version" placeholder="e.g. 0.1.0" required></label>
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Mail</h1>
|
||||||
|
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
|
||||||
|
{{with .Data}}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Deliveries</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Delivery</th><th>Template</th><th>Status</th><th>Attempts</th><th>Next attempt</th><th>Created</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Deliveries}}
|
||||||
|
<tr><td><a href="/_gm/mail/deliveries/{{.DeliveryID}}"><code>{{.DeliveryID}}</code></a></td><td>{{.Template}}</td><td>{{.Status}}</td><td>{{.Attempts}}</td><td>{{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}}</td><td>{{.Created}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="6"><span class="note">no deliveries</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<nav class="pager">
|
||||||
|
{{if .HasPrev}}<a href="/_gm/mail?page={{.PrevPage}}&page_size={{.PageSize}}">« prev</a>{{end}}
|
||||||
|
<span>page {{.Page}} · {{.Total}} total</span>
|
||||||
|
{{if .HasNext}}<a href="/_gm/mail?page={{.NextPage}}&page_size={{.PageSize}}">next »</a>{{end}}
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Dead-letters</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Delivery</th><th>Reason</th><th>Archived</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .DeadLetters}}<tr><td><a href="/_gm/mail/deliveries/{{.DeliveryID}}"><code>{{.DeliveryID}}</code></a></td><td>{{.Reason}}</td><td>{{.Archived}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="3"><span class="note">no dead-letters</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{$csrf := .CSRFToken}}
|
||||||
|
{{with .Data}}
|
||||||
|
<p><a href="/_gm/mail">« mail</a></p>
|
||||||
|
<h1>Delivery</h1>
|
||||||
|
<section class="panel">
|
||||||
|
<ul class="kv">
|
||||||
|
<li>Delivery ID: <code>{{.DeliveryID}}</code></li>
|
||||||
|
<li>Template: {{.Template}}</li>
|
||||||
|
<li>Status: {{.Status}}</li>
|
||||||
|
<li>Attempts: {{.Attempts}}</li>
|
||||||
|
<li>Next attempt: {{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}}</li>
|
||||||
|
<li>Created: {{.Created}}</li>
|
||||||
|
<li>Sent: {{if .Sent}}{{.Sent}}{{else}}—{{end}}</li>
|
||||||
|
<li>Dead-lettered: {{if .DeadLettered}}{{.DeadLettered}}{{else}}—{{end}}</li>
|
||||||
|
<li>Last error: {{if .LastError}}{{.LastError}}{{else}}—{{end}}</li>
|
||||||
|
</ul>
|
||||||
|
{{if .CanResend}}
|
||||||
|
<form method="post" action="/_gm/mail/deliveries/{{.DeliveryID}}/resend" class="form" onsubmit="return confirm('Resend this delivery?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Resend</button></form>
|
||||||
|
{{else}}<p class="note">Already sent — resend is not available.</p>{{end}}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Attempts</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>#</th><th>Outcome</th><th>Started</th><th>Finished</th><th>Error</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .AttemptRows}}<tr><td>{{.AttemptNo}}</td><td>{{.Outcome}}</td><td>{{.Started}}</td><td>{{if .Finished}}{{.Finished}}{{else}}—{{end}}</td><td>{{.Error}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="5"><span class="note">no attempts</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>{{.Title}}</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<p class="{{.Class}}">{{.Message}}</p>
|
||||||
|
{{if .BackHref}}<p><a href="{{.BackHref}}">« back</a></p>{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Notifications</h1>
|
||||||
|
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
|
||||||
|
{{with .Data}}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Recent notifications</h2>
|
||||||
|
<table class="list"><thead><tr><th>ID</th><th>Kind</th><th>User</th><th>Created</th></tr></thead><tbody>
|
||||||
|
{{range .Notifications}}<tr><td><code>{{.NotificationID}}</code></td><td>{{.Kind}}</td><td>{{.UserID}}</td><td>{{.Created}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="4"><span class="note">none</span></td></tr>{{end}}
|
||||||
|
</tbody></table>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Dead-letters</h2>
|
||||||
|
<table class="list"><thead><tr><th>Notification</th><th>Route</th><th>Reason</th><th>Archived</th></tr></thead><tbody>
|
||||||
|
{{range .DeadLetters}}<tr><td><code>{{.NotificationID}}</code></td><td><code>{{.RouteID}}</code></td><td>{{.Reason}}</td><td>{{.Archived}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="4"><span class="note">none</span></td></tr>{{end}}
|
||||||
|
</tbody></table>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Malformed intents</h2>
|
||||||
|
<table class="list"><thead><tr><th>ID</th><th>Reason</th><th>Received</th></tr></thead><tbody>
|
||||||
|
{{range .Malformed}}<tr><td><code>{{.ID}}</code></td><td>{{.Reason}}</td><td>{{.Received}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="3"><span class="note">none</span></td></tr>{{end}}
|
||||||
|
</tbody></table>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{$csrf := .CSRFToken}}
|
||||||
|
<h1>Operators</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Username</th><th>Status</th><th>Created</th><th>Last used</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Items}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Username}}</td>
|
||||||
|
<td>{{if .Disabled}}<span class="bad">disabled</span>{{else}}<span class="ok">active</span>{{end}}</td>
|
||||||
|
<td>{{.CreatedAt}}</td>
|
||||||
|
<td>{{if .LastUsedAt}}{{.LastUsedAt}}{{else}}—{{end}}</td>
|
||||||
|
<td>
|
||||||
|
<div class="actions">
|
||||||
|
{{if .Disabled}}
|
||||||
|
<form method="post" action="/_gm/operators/{{.Username}}/enable"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Enable</button></form>
|
||||||
|
{{else}}
|
||||||
|
<form method="post" action="/_gm/operators/{{.Username}}/disable" onsubmit="return confirm('Disable {{.Username}}?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Disable</button></form>
|
||||||
|
{{end}}
|
||||||
|
<form method="post" action="/_gm/operators/{{.Username}}/reset-password" class="form"><input type="hidden" name="_csrf" value="{{$csrf}}"><input type="password" name="password" placeholder="new password" required><button type="submit">Reset</button></form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}<tr><td colspan="5"><span class="note">no operators</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Create operator</h2>
|
||||||
|
<form method="post" action="/_gm/operators" class="form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||||
|
<label>Username <input type="text" name="username" required></label>
|
||||||
|
<label>Password <input type="password" name="password" required></label>
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{$csrf := .CSRFToken}}
|
||||||
|
{{with .Data}}
|
||||||
|
<p><a href="/_gm/users">« all users</a></p>
|
||||||
|
<h1>{{.Email}}</h1>
|
||||||
|
{{if .Deleted}}<p class="bad">This account is soft-deleted.</p>{{end}}
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Account</h2>
|
||||||
|
<ul class="kv">
|
||||||
|
<li>User ID: <code>{{.UserID}}</code></li>
|
||||||
|
<li>User name: {{.UserName}}</li>
|
||||||
|
<li>Display name: {{.DisplayName}}</li>
|
||||||
|
<li>Preferred language: {{.PreferredLanguage}}</li>
|
||||||
|
<li>Time zone: {{.TimeZone}}</li>
|
||||||
|
<li>Declared country: {{.DeclaredCountry}}</li>
|
||||||
|
<li>Status: {{if .Blocked}}<span class="bad">blocked</span>{{else}}<span class="ok">active</span>{{end}}</li>
|
||||||
|
<li>Created: {{.CreatedAt}}</li>
|
||||||
|
<li>Updated: {{.UpdatedAt}}</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Entitlement</h2>
|
||||||
|
<ul class="kv">
|
||||||
|
<li>Tier: <strong>{{.Tier}}</strong> ({{if .IsPaid}}paid{{else}}free{{end}})</li>
|
||||||
|
<li>Source: {{.EntitlementSource}}</li>
|
||||||
|
<li>Reason: {{.EntitlementReason}}</li>
|
||||||
|
<li>Ends: {{if .EntitlementEnds}}{{.EntitlementEnds}}{{else}}—{{end}}</li>
|
||||||
|
</ul>
|
||||||
|
<form method="post" action="/_gm/users/{{.UserID}}/entitlement" class="form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||||
|
<label>Tier
|
||||||
|
<select name="tier">{{range .Tiers}}<option value="{{.}}">{{.}}</option>{{end}}</select>
|
||||||
|
</label>
|
||||||
|
<label>Source <input type="text" name="source" value="admin"></label>
|
||||||
|
<label>Reason <input type="text" name="reason_code" placeholder="optional"></label>
|
||||||
|
<button type="submit">Update entitlement</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Active sanctions</h2>
|
||||||
|
{{if .Sanctions}}
|
||||||
|
<table class="counts"><tbody>
|
||||||
|
{{range .Sanctions}}<tr><td>{{.SanctionCode}}</td><td>{{.Scope}}</td><td>{{.ReasonCode}}</td><td>{{.AppliedAt}}</td></tr>{{end}}
|
||||||
|
</tbody></table>
|
||||||
|
{{else}}<p class="note">none</p>{{end}}
|
||||||
|
{{if .Blocked}}
|
||||||
|
<p class="note">User is permanently blocked. Unblock is not available in the current admin API.</p>
|
||||||
|
{{else}}
|
||||||
|
<form method="post" action="/_gm/users/{{.UserID}}/block" class="form" onsubmit="return confirm('Permanently block this user?');">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||||
|
<label>Reason <input type="text" name="reason_code" required></label>
|
||||||
|
<button type="submit" class="danger">Permanently block</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Danger zone</h2>
|
||||||
|
<form method="post" action="/_gm/users/{{.UserID}}/soft-delete" class="form" onsubmit="return confirm('Soft-delete this account? This cascades to sessions, memberships, and owned games.');">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||||
|
<button type="submit" class="danger">Soft-delete account</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Users</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Email</th><th>User name</th><th>Display</th><th>Tier</th><th>Status</th><th>Created</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Items}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/_gm/users/{{.UserID}}">{{.Email}}</a></td>
|
||||||
|
<td>{{.UserName}}</td>
|
||||||
|
<td>{{.DisplayName}}</td>
|
||||||
|
<td>{{.Tier}}</td>
|
||||||
|
<td>{{if .Deleted}}<span class="bad">deleted</span>{{else if .Blocked}}<span class="bad">blocked</span>{{else}}<span class="ok">active</span>{{end}}</td>
|
||||||
|
<td>{{.CreatedAt}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="6"><span class="note">no users</span></td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<nav class="pager">
|
||||||
|
{{if .HasPrev}}<a href="/_gm/users?page={{.PrevPage}}&page_size={{.PageSize}}">« prev</a>{{end}}
|
||||||
|
<span>page {{.Page}} · {{.Total}} total</span>
|
||||||
|
{{if .HasNext}}<a href="/_gm/users?page={{.NextPage}}&page_size={{.PageSize}}">next »</a>{{end}}
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package adminconsole
|
||||||
|
|
||||||
|
// UserRow is one line in the users list table.
|
||||||
|
type UserRow struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
UserName string
|
||||||
|
DisplayName string
|
||||||
|
Tier string
|
||||||
|
Blocked bool
|
||||||
|
Deleted bool
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsersListData is the view model for the paginated users list.
|
||||||
|
type UsersListData struct {
|
||||||
|
Items []UserRow
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
Total int
|
||||||
|
HasPrev bool
|
||||||
|
HasNext bool
|
||||||
|
PrevPage int
|
||||||
|
NextPage int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanctionView is one active sanction shown on the user detail page.
|
||||||
|
type SanctionView struct {
|
||||||
|
SanctionCode string
|
||||||
|
Scope string
|
||||||
|
ReasonCode string
|
||||||
|
AppliedAt string
|
||||||
|
ExpiresAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserDetailData is the view model for a single user's detail page,
|
||||||
|
// combining the account aggregate with the form option lists.
|
||||||
|
type UserDetailData struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
UserName string
|
||||||
|
DisplayName string
|
||||||
|
PreferredLanguage string
|
||||||
|
TimeZone string
|
||||||
|
DeclaredCountry string
|
||||||
|
Blocked bool
|
||||||
|
Deleted bool
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
|
||||||
|
Tier string
|
||||||
|
IsPaid bool
|
||||||
|
EntitlementSource string
|
||||||
|
EntitlementReason string
|
||||||
|
EntitlementEnds string
|
||||||
|
|
||||||
|
Sanctions []SanctionView
|
||||||
|
|
||||||
|
// Tiers lists the selectable entitlement tiers for the form.
|
||||||
|
Tiers []string
|
||||||
|
}
|
||||||
@@ -55,6 +55,8 @@ const (
|
|||||||
envAdminBootstrapUser = "BACKEND_ADMIN_BOOTSTRAP_USER"
|
envAdminBootstrapUser = "BACKEND_ADMIN_BOOTSTRAP_USER"
|
||||||
envAdminBootstrapPassword = "BACKEND_ADMIN_BOOTSTRAP_PASSWORD"
|
envAdminBootstrapPassword = "BACKEND_ADMIN_BOOTSTRAP_PASSWORD"
|
||||||
|
|
||||||
|
envAdminConsoleCSRFKey = "BACKEND_ADMIN_CONSOLE_CSRF_KEY"
|
||||||
|
|
||||||
envGeoIPDBPath = "BACKEND_GEOIP_DB_PATH"
|
envGeoIPDBPath = "BACKEND_GEOIP_DB_PATH"
|
||||||
|
|
||||||
envOTelTracesExporter = "BACKEND_OTEL_TRACES_EXPORTER"
|
envOTelTracesExporter = "BACKEND_OTEL_TRACES_EXPORTER"
|
||||||
@@ -103,11 +105,6 @@ const (
|
|||||||
envDiplomailTranslatorTimeout = "BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT"
|
envDiplomailTranslatorTimeout = "BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT"
|
||||||
envDiplomailTranslatorMaxAttempts = "BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS"
|
envDiplomailTranslatorMaxAttempts = "BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS"
|
||||||
envDiplomailWorkerInterval = "BACKEND_DIPLOMAIL_WORKER_INTERVAL"
|
envDiplomailWorkerInterval = "BACKEND_DIPLOMAIL_WORKER_INTERVAL"
|
||||||
|
|
||||||
envDevSandboxEmail = "BACKEND_DEV_SANDBOX_EMAIL"
|
|
||||||
envDevSandboxEngineImage = "BACKEND_DEV_SANDBOX_ENGINE_IMAGE"
|
|
||||||
envDevSandboxEngineVersion = "BACKEND_DEV_SANDBOX_ENGINE_VERSION"
|
|
||||||
envDevSandboxPlayerCount = "BACKEND_DEV_SANDBOX_PLAYER_COUNT"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Default values applied when an environment variable is absent.
|
// Default values applied when an environment variable is absent.
|
||||||
@@ -176,9 +173,6 @@ const (
|
|||||||
defaultDiplomailTranslatorTimeout = 10 * time.Second
|
defaultDiplomailTranslatorTimeout = 10 * time.Second
|
||||||
defaultDiplomailTranslatorMaxAttempts = 5
|
defaultDiplomailTranslatorMaxAttempts = 5
|
||||||
defaultDiplomailWorkerInterval = 2 * time.Second
|
defaultDiplomailWorkerInterval = 2 * time.Second
|
||||||
|
|
||||||
defaultDevSandboxEngineVersion = "0.1.0"
|
|
||||||
defaultDevSandboxPlayerCount = 20
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Allowed values for the closed-set string options.
|
// Allowed values for the closed-set string options.
|
||||||
@@ -208,6 +202,7 @@ type Config struct {
|
|||||||
Docker DockerConfig
|
Docker DockerConfig
|
||||||
Game GameConfig
|
Game GameConfig
|
||||||
Admin AdminBootstrapConfig
|
Admin AdminBootstrapConfig
|
||||||
|
AdminConsole AdminConsoleConfig
|
||||||
GeoIP GeoIPConfig
|
GeoIP GeoIPConfig
|
||||||
Telemetry TelemetryConfig
|
Telemetry TelemetryConfig
|
||||||
Auth AuthConfig
|
Auth AuthConfig
|
||||||
@@ -216,29 +211,12 @@ type Config struct {
|
|||||||
Runtime RuntimeConfig
|
Runtime RuntimeConfig
|
||||||
Notification NotificationConfig
|
Notification NotificationConfig
|
||||||
Diplomail DiplomailConfig
|
Diplomail DiplomailConfig
|
||||||
DevSandbox DevSandboxConfig
|
|
||||||
|
|
||||||
// FreshnessWindow mirrors the gateway freshness window and is used by the
|
// FreshnessWindow mirrors the gateway freshness window and is used by the
|
||||||
// push server to bound the cursor TTL.
|
// push server to bound the cursor TTL.
|
||||||
FreshnessWindow time.Duration
|
FreshnessWindow time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// DevSandboxConfig configures the boot-time bootstrap implemented in
|
|
||||||
// `backend/internal/devsandbox`. When Email is empty the bootstrap
|
|
||||||
// is a no-op, which is the production posture. When Email is set —
|
|
||||||
// from `BACKEND_DEV_SANDBOX_EMAIL` in the `tools/local-dev` stack —
|
|
||||||
// the bootstrap idempotently provisions a real user, the configured
|
|
||||||
// number of dummy participants, a private "Dev Sandbox" game, the
|
|
||||||
// matching memberships, and drives the lifecycle to `running`. The
|
|
||||||
// engine image and engine version refer to a row that the bootstrap
|
|
||||||
// also seeds in `engine_versions`.
|
|
||||||
type DevSandboxConfig struct {
|
|
||||||
Email string
|
|
||||||
EngineImage string
|
|
||||||
EngineVersion string
|
|
||||||
PlayerCount int
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoggingConfig stores the parameters used by the structured logger.
|
// LoggingConfig stores the parameters used by the structured logger.
|
||||||
type LoggingConfig struct {
|
type LoggingConfig struct {
|
||||||
// Level is the zap level name (e.g. "debug", "info", "warn", "error").
|
// Level is the zap level name (e.g. "debug", "info", "warn", "error").
|
||||||
@@ -308,6 +286,15 @@ type AdminBootstrapConfig struct {
|
|||||||
Password string
|
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.
|
// GeoIPConfig configures the GeoLite2 country database used by geo lookups.
|
||||||
type GeoIPConfig struct {
|
type GeoIPConfig struct {
|
||||||
DBPath string
|
DBPath string
|
||||||
@@ -560,10 +547,6 @@ func DefaultConfig() Config {
|
|||||||
TranslatorMaxAttempts: defaultDiplomailTranslatorMaxAttempts,
|
TranslatorMaxAttempts: defaultDiplomailTranslatorMaxAttempts,
|
||||||
WorkerInterval: defaultDiplomailWorkerInterval,
|
WorkerInterval: defaultDiplomailWorkerInterval,
|
||||||
},
|
},
|
||||||
DevSandbox: DevSandboxConfig{
|
|
||||||
EngineVersion: defaultDevSandboxEngineVersion,
|
|
||||||
PlayerCount: defaultDevSandboxPlayerCount,
|
|
||||||
},
|
|
||||||
Runtime: RuntimeConfig{
|
Runtime: RuntimeConfig{
|
||||||
WorkerPoolSize: defaultRuntimeWorkerPoolSize,
|
WorkerPoolSize: defaultRuntimeWorkerPoolSize,
|
||||||
JobQueueSize: defaultRuntimeJobQueueSize,
|
JobQueueSize: defaultRuntimeJobQueueSize,
|
||||||
@@ -644,6 +627,8 @@ func LoadFromEnv() (Config, error) {
|
|||||||
cfg.Admin.User = loadString(envAdminBootstrapUser, cfg.Admin.User)
|
cfg.Admin.User = loadString(envAdminBootstrapUser, cfg.Admin.User)
|
||||||
cfg.Admin.Password = loadString(envAdminBootstrapPassword, cfg.Admin.Password)
|
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.GeoIP.DBPath = loadString(envGeoIPDBPath, cfg.GeoIP.DBPath)
|
||||||
|
|
||||||
cfg.Telemetry.TracesExporter = strings.ToLower(loadString(envOTelTracesExporter, cfg.Telemetry.TracesExporter))
|
cfg.Telemetry.TracesExporter = strings.ToLower(loadString(envOTelTracesExporter, cfg.Telemetry.TracesExporter))
|
||||||
@@ -741,13 +726,6 @@ func LoadFromEnv() (Config, error) {
|
|||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.DevSandbox.Email = strings.TrimSpace(loadString(envDevSandboxEmail, cfg.DevSandbox.Email))
|
|
||||||
cfg.DevSandbox.EngineImage = strings.TrimSpace(loadString(envDevSandboxEngineImage, cfg.DevSandbox.EngineImage))
|
|
||||||
cfg.DevSandbox.EngineVersion = strings.TrimSpace(loadString(envDevSandboxEngineVersion, cfg.DevSandbox.EngineVersion))
|
|
||||||
if cfg.DevSandbox.PlayerCount, err = loadInt(envDevSandboxPlayerCount, cfg.DevSandbox.PlayerCount); err != nil {
|
|
||||||
return Config{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
@@ -959,21 +937,6 @@ func (c Config) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if email := strings.TrimSpace(c.DevSandbox.Email); email != "" {
|
|
||||||
if _, err := netmail.ParseAddress(email); err != nil {
|
|
||||||
return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envDevSandboxEmail, err)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(c.DevSandbox.EngineImage) == "" {
|
|
||||||
return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineImage, envDevSandboxEmail)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(c.DevSandbox.EngineVersion) == "" {
|
|
||||||
return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineVersion, envDevSandboxEmail)
|
|
||||||
}
|
|
||||||
if c.DevSandbox.PlayerCount <= 0 {
|
|
||||||
return fmt.Errorf("%s must be positive when %s is set", envDevSandboxPlayerCount, envDevSandboxEmail)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,287 +0,0 @@
|
|||||||
// Package devsandbox provisions a ready-to-play game on backend boot
|
|
||||||
// for the `tools/local-dev` stack.
|
|
||||||
//
|
|
||||||
// Bootstrap is invoked from `backend/cmd/backend/main.go` after the
|
|
||||||
// admin bootstrap and before the HTTP listener starts. It reads
|
|
||||||
// `cfg.DevSandbox`; when `Email` is empty (the production posture)
|
|
||||||
// the function logs "skipped" and returns nil. When set, it
|
|
||||||
// idempotently:
|
|
||||||
//
|
|
||||||
// 1. registers the configured engine version and image;
|
|
||||||
// 2. find-or-creates the real dev user with the configured email;
|
|
||||||
// 3. find-or-creates `cfg.PlayerCount - 1` deterministic dummy
|
|
||||||
// users so the engine's minimum-players constraint is met;
|
|
||||||
// 4. find-or-creates a private "Dev Sandbox" game owned by the
|
|
||||||
// real user with min/max_players = cfg.PlayerCount and a
|
|
||||||
// year-out turn schedule (effectively frozen at turn 1);
|
|
||||||
// 5. inserts memberships for all participants bypassing the
|
|
||||||
// application/approval flow;
|
|
||||||
// 6. drives the lifecycle to `running` (or as far as possible if
|
|
||||||
// the runtime is busy).
|
|
||||||
//
|
|
||||||
// The function is a no-op on subsequent boots once the game is
|
|
||||||
// running; partial states from earlier crashes are recovered.
|
|
||||||
package devsandbox
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"galaxy/backend/internal/config"
|
|
||||||
"galaxy/backend/internal/lobby"
|
|
||||||
"galaxy/backend/internal/runtime"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SandboxGameName is the display name used to identify the
|
|
||||||
// auto-provisioned game on subsequent reboots. The combination of
|
|
||||||
// game_name and owner_user_id is unique enough in practice — only
|
|
||||||
// the dev sandbox bootstrap creates a game owned by the configured
|
|
||||||
// real user with this exact name.
|
|
||||||
const SandboxGameName = "Dev Sandbox"
|
|
||||||
|
|
||||||
// SandboxTurnSchedule keeps the game on turn 1 by scheduling the
|
|
||||||
// next turn a year out. The runtime scheduler still parses this and
|
|
||||||
// will tick once a year — long enough to never interfere with
|
|
||||||
// solo UI development.
|
|
||||||
const SandboxTurnSchedule = "0 0 1 1 *"
|
|
||||||
|
|
||||||
// UserEnsurer matches `auth.UserEnsurer`. We define a local
|
|
||||||
// interface to avoid importing the auth package and circular
|
|
||||||
// dependencies — the production wiring passes the same `*user.Service`
|
|
||||||
// instance used by auth.
|
|
||||||
type UserEnsurer interface {
|
|
||||||
EnsureByEmail(ctx context.Context, email, preferredLanguage, timeZone, declaredCountry string) (uuid.UUID, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deps aggregates the collaborators Bootstrap needs.
|
|
||||||
type Deps struct {
|
|
||||||
Users UserEnsurer
|
|
||||||
Lobby *lobby.Service
|
|
||||||
EngineVersions *runtime.EngineVersionService
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bootstrap runs the seven-step provisioning flow described on the
|
|
||||||
// package doc comment. Errors are returned to the caller; the boot
|
|
||||||
// path in `cmd/backend/main.go` aborts startup if Bootstrap fails so
|
|
||||||
// a misconfigured dev environment surfaces immediately rather than
|
|
||||||
// silently leaving the lobby empty.
|
|
||||||
func Bootstrap(ctx context.Context, deps Deps, cfg config.DevSandboxConfig, logger *zap.Logger) error {
|
|
||||||
if logger == nil {
|
|
||||||
logger = zap.NewNop()
|
|
||||||
}
|
|
||||||
logger = logger.Named("dev_sandbox")
|
|
||||||
|
|
||||||
if cfg.Email == "" {
|
|
||||||
logger.Info("skipped (no email)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if deps.Users == nil || deps.Lobby == nil || deps.EngineVersions == nil {
|
|
||||||
return errors.New("dev_sandbox: deps.Users, deps.Lobby and deps.EngineVersions are required")
|
|
||||||
}
|
|
||||||
if cfg.PlayerCount <= 0 {
|
|
||||||
return fmt.Errorf("dev_sandbox: PlayerCount must be positive, got %d", cfg.PlayerCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ensureEngineVersion(ctx, deps.EngineVersions, cfg, logger); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
realID, err := deps.Users.EnsureByEmail(ctx, cfg.Email, "en", "UTC", "")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("dev_sandbox: ensure real user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dummyIDs := make([]uuid.UUID, 0, cfg.PlayerCount-1)
|
|
||||||
for i := 1; i < cfg.PlayerCount; i++ {
|
|
||||||
email := fmt.Sprintf("dev-dummy-%02d@local.test", i)
|
|
||||||
id, err := deps.Users.EnsureByEmail(ctx, email, "en", "UTC", "")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("dev_sandbox: ensure dummy %d: %w", i, err)
|
|
||||||
}
|
|
||||||
dummyIDs = append(dummyIDs, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := purgeTerminalSandboxGames(ctx, deps.Lobby, realID, logger); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
game, err := findOrCreateSandboxGame(ctx, deps.Lobby, realID, cfg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
game, err = ensureMembershipsAndDrive(ctx, deps.Lobby, game, realID, dummyIDs, logger)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("bootstrap complete",
|
|
||||||
zap.String("user_id", realID.String()),
|
|
||||||
zap.String("game_id", game.GameID.String()),
|
|
||||||
zap.String("status", game.Status),
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureEngineVersion(ctx context.Context, svc *runtime.EngineVersionService, cfg config.DevSandboxConfig, logger *zap.Logger) error {
|
|
||||||
_, err := svc.Register(ctx, runtime.RegisterInput{
|
|
||||||
Version: cfg.EngineVersion,
|
|
||||||
ImageRef: cfg.EngineImage,
|
|
||||||
})
|
|
||||||
switch {
|
|
||||||
case err == nil:
|
|
||||||
logger.Info("engine version registered",
|
|
||||||
zap.String("version", cfg.EngineVersion),
|
|
||||||
zap.String("image", cfg.EngineImage),
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
case errors.Is(err, runtime.ErrEngineVersionTaken):
|
|
||||||
logger.Debug("engine version already registered",
|
|
||||||
zap.String("version", cfg.EngineVersion),
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("dev_sandbox: register engine version: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// terminalSandboxStatus reports whether a sandbox game has reached a
|
|
||||||
// state from which it can no longer be driven back to running. We
|
|
||||||
// treat such games as "absent" so the next bootstrap creates a fresh
|
|
||||||
// one rather than handing the developer a dead lobby tile.
|
|
||||||
func terminalSandboxStatus(status string) bool {
|
|
||||||
switch status {
|
|
||||||
case lobby.GameStatusCancelled, lobby.GameStatusFinished, lobby.GameStatusStartFailed:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// purgeTerminalSandboxGames deletes every previous "Dev Sandbox" game
|
|
||||||
// the dev user owns that has reached a terminal state
|
|
||||||
// (cancelled / finished / start_failed). The cascade declared in
|
|
||||||
// `00001_init.sql` removes the matching memberships, applications,
|
|
||||||
// invites, runtime records, and player mappings in the same write,
|
|
||||||
// so the developer's lobby never piles up dead tiles between
|
|
||||||
// `make rebuild` cycles. Non-terminal games are left untouched —
|
|
||||||
// a `running` sandbox from a previous boot is the happy path.
|
|
||||||
func purgeTerminalSandboxGames(ctx context.Context, svc *lobby.Service, ownerID uuid.UUID, logger *zap.Logger) error {
|
|
||||||
games, err := svc.ListMyGames(ctx, ownerID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("dev_sandbox: list my games: %w", err)
|
|
||||||
}
|
|
||||||
for _, g := range games {
|
|
||||||
if g.GameName != SandboxGameName || g.OwnerUserID == nil || *g.OwnerUserID != ownerID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !terminalSandboxStatus(g.Status) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := svc.DeleteGame(ctx, g.GameID); err != nil {
|
|
||||||
return fmt.Errorf("dev_sandbox: delete terminal sandbox %s: %w", g.GameID, err)
|
|
||||||
}
|
|
||||||
logger.Info("purged terminal sandbox game",
|
|
||||||
zap.String("game_id", g.GameID.String()),
|
|
||||||
zap.String("status", g.Status),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func findOrCreateSandboxGame(ctx context.Context, svc *lobby.Service, ownerID uuid.UUID, cfg config.DevSandboxConfig) (lobby.GameRecord, error) {
|
|
||||||
games, err := svc.ListMyGames(ctx, ownerID)
|
|
||||||
if err != nil {
|
|
||||||
return lobby.GameRecord{}, fmt.Errorf("dev_sandbox: list my games: %w", err)
|
|
||||||
}
|
|
||||||
for _, g := range games {
|
|
||||||
if g.GameName != SandboxGameName || g.OwnerUserID == nil || *g.OwnerUserID != ownerID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// `purgeTerminalSandboxGames` ran before us, so any sandbox
|
|
||||||
// game still in the list is either a live one we should
|
|
||||||
// reuse or a transient state we can drive forward.
|
|
||||||
return g, nil
|
|
||||||
}
|
|
||||||
rec, err := svc.CreateGame(ctx, lobby.CreateGameInput{
|
|
||||||
OwnerUserID: &ownerID,
|
|
||||||
Visibility: lobby.VisibilityPrivate,
|
|
||||||
GameName: SandboxGameName,
|
|
||||||
Description: "Auto-provisioned by backend/internal/devsandbox for solo UI development.",
|
|
||||||
MinPlayers: int32(cfg.PlayerCount),
|
|
||||||
MaxPlayers: int32(cfg.PlayerCount),
|
|
||||||
StartGapHours: 0,
|
|
||||||
StartGapPlayers: 0,
|
|
||||||
EnrollmentEndsAt: time.Now().Add(365 * 24 * time.Hour),
|
|
||||||
TurnSchedule: SandboxTurnSchedule,
|
|
||||||
TargetEngineVersion: cfg.EngineVersion,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return lobby.GameRecord{}, fmt.Errorf("dev_sandbox: create game: %w", err)
|
|
||||||
}
|
|
||||||
return rec, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureMembershipsAndDrive(ctx context.Context, svc *lobby.Service, game lobby.GameRecord, realID uuid.UUID, dummyIDs []uuid.UUID, logger *zap.Logger) (lobby.GameRecord, error) {
|
|
||||||
caller := realID
|
|
||||||
if game.Status == lobby.GameStatusDraft {
|
|
||||||
next, err := svc.OpenEnrollment(ctx, &caller, false, game.GameID)
|
|
||||||
if err != nil {
|
|
||||||
return game, fmt.Errorf("dev_sandbox: open enrollment: %w", err)
|
|
||||||
}
|
|
||||||
game = next
|
|
||||||
}
|
|
||||||
|
|
||||||
if game.Status == lobby.GameStatusEnrollmentOpen {
|
|
||||||
users := append([]uuid.UUID{realID}, dummyIDs...)
|
|
||||||
for i, uid := range users {
|
|
||||||
raceName := fmt.Sprintf("Sandbox-%02d", i+1)
|
|
||||||
if _, err := svc.InsertMembershipDirect(ctx, lobby.InsertMembershipDirectInput{
|
|
||||||
GameID: game.GameID,
|
|
||||||
UserID: uid,
|
|
||||||
RaceName: raceName,
|
|
||||||
}); err != nil {
|
|
||||||
return game, fmt.Errorf("dev_sandbox: insert membership %d: %w", i+1, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.Info("memberships ensured",
|
|
||||||
zap.Int("count", len(users)),
|
|
||||||
zap.String("game_id", game.GameID.String()),
|
|
||||||
)
|
|
||||||
next, err := svc.ReadyToStart(ctx, &caller, false, game.GameID)
|
|
||||||
if err != nil {
|
|
||||||
return game, fmt.Errorf("dev_sandbox: ready to start: %w", err)
|
|
||||||
}
|
|
||||||
game = next
|
|
||||||
}
|
|
||||||
|
|
||||||
if game.Status == lobby.GameStatusReadyToStart {
|
|
||||||
next, err := svc.Start(ctx, &caller, false, game.GameID)
|
|
||||||
if err != nil {
|
|
||||||
return game, fmt.Errorf("dev_sandbox: start: %w", err)
|
|
||||||
}
|
|
||||||
game = next
|
|
||||||
}
|
|
||||||
|
|
||||||
if game.Status == lobby.GameStatusStartFailed {
|
|
||||||
next, err := svc.RetryStart(ctx, &caller, false, game.GameID)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn("retry start failed", zap.Error(err))
|
|
||||||
return game, nil
|
|
||||||
}
|
|
||||||
game = next
|
|
||||||
if game.Status == lobby.GameStatusReadyToStart {
|
|
||||||
next, err := svc.Start(ctx, &caller, false, game.GameID)
|
|
||||||
if err != nil {
|
|
||||||
return game, fmt.Errorf("dev_sandbox: start after retry: %w", err)
|
|
||||||
}
|
|
||||||
game = next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return game, nil
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package devsandbox
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"galaxy/backend/internal/config"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestBootstrapSkippedWhenEmailEmpty exercises the no-op branch: with
|
|
||||||
// the production posture (Email == "") Bootstrap must return without
|
|
||||||
// touching any dependency. The fact that Users/Lobby/EngineVersions
|
|
||||||
// are nil here doubles as a check that the early-return runs first.
|
|
||||||
func TestBootstrapSkippedWhenEmailEmpty(t *testing.T) {
|
|
||||||
err := Bootstrap(
|
|
||||||
context.Background(),
|
|
||||||
Deps{},
|
|
||||||
config.DevSandboxConfig{},
|
|
||||||
zap.NewNop(),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected nil error on empty email, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBootstrapRejectsZeroPlayerCount confirms the validation
|
|
||||||
// short-circuits the flow before any DB call when PlayerCount is
|
|
||||||
// non-positive but Email is set. The error path is fast and never
|
|
||||||
// dereferences the (still-nil) Users/Lobby deps.
|
|
||||||
func TestBootstrapRejectsZeroPlayerCount(t *testing.T) {
|
|
||||||
err := Bootstrap(
|
|
||||||
context.Background(),
|
|
||||||
Deps{Users: stubEnsurer{}, Lobby: nil, EngineVersions: nil},
|
|
||||||
config.DevSandboxConfig{
|
|
||||||
Email: "dev@local.test",
|
|
||||||
EngineImage: "galaxy-engine:local-dev",
|
|
||||||
EngineVersion: "0.0.0-local-dev",
|
|
||||||
PlayerCount: 0,
|
|
||||||
},
|
|
||||||
zap.NewNop(),
|
|
||||||
)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error on zero PlayerCount, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBootstrapRejectsMissingDeps checks that a misconfigured wiring
|
|
||||||
// (Email set but one of the required services nil) fails fast rather
|
|
||||||
// than panicking when the bootstrap reaches its first service call.
|
|
||||||
func TestBootstrapRejectsMissingDeps(t *testing.T) {
|
|
||||||
err := Bootstrap(
|
|
||||||
context.Background(),
|
|
||||||
Deps{Users: stubEnsurer{}, Lobby: nil, EngineVersions: nil},
|
|
||||||
config.DevSandboxConfig{
|
|
||||||
Email: "dev@local.test",
|
|
||||||
EngineImage: "galaxy-engine:local-dev",
|
|
||||||
EngineVersion: "0.0.0-local-dev",
|
|
||||||
PlayerCount: 20,
|
|
||||||
},
|
|
||||||
zap.NewNop(),
|
|
||||||
)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error on missing deps, got nil")
|
|
||||||
}
|
|
||||||
if !errors.Is(err, errMissingDepsSentinel) && err.Error() == "" {
|
|
||||||
// The exact wording is not part of the contract; this branch
|
|
||||||
// only asserts the error is non-nil and human-readable.
|
|
||||||
t.Fatalf("error has empty message: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// errMissingDepsSentinel exists so the assertion above can compile;
|
|
||||||
// the real error is constructed via errors.New inside Bootstrap and
|
|
||||||
// is intentionally not exported. The test only needs to confirm the
|
|
||||||
// returned error has a message.
|
|
||||||
var errMissingDepsSentinel = errors.New("sentinel")
|
|
||||||
|
|
||||||
// TestTerminalSandboxStatus pins the contract that decides whether a
|
|
||||||
// previously created sandbox game gets purged on the next boot.
|
|
||||||
// Terminal states are deleted (cascade-style) so the developer's
|
|
||||||
// lobby never piles up dead tiles between `make rebuild` cycles.
|
|
||||||
func TestTerminalSandboxStatus(t *testing.T) {
|
|
||||||
terminal := []string{"cancelled", "finished", "start_failed"}
|
|
||||||
live := []string{"draft", "enrollment_open", "ready_to_start", "starting", "running", "paused"}
|
|
||||||
|
|
||||||
for _, status := range terminal {
|
|
||||||
if !terminalSandboxStatus(status) {
|
|
||||||
t.Errorf("expected %q to be terminal", status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, status := range live {
|
|
||||||
if terminalSandboxStatus(status) {
|
|
||||||
t.Errorf("expected %q to be non-terminal", status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type stubEnsurer struct{}
|
|
||||||
|
|
||||||
func (stubEnsurer) EnsureByEmail(_ context.Context, _, _, _, _ string) (uuid.UUID, error) {
|
|
||||||
return uuid.UUID{}, nil
|
|
||||||
}
|
|
||||||
@@ -274,11 +274,10 @@ func (s *Service) ListFinishedGamesBefore(ctx context.Context, cutoff time.Time)
|
|||||||
// `ON DELETE CASCADE` constraints declared in `00001_init.sql`.
|
// `ON DELETE CASCADE` constraints declared in `00001_init.sql`.
|
||||||
// Idempotent: returns nil when no game matches.
|
// Idempotent: returns nil when no game matches.
|
||||||
//
|
//
|
||||||
// Phase 14 introduces this method for the dev-sandbox bootstrap so a
|
// `DeleteGame` is destructive — a hard delete that bypasses the
|
||||||
// terminal "Dev Sandbox" tile from a previous local-dev session can
|
// cascade-notification machinery — so production callers stay on the
|
||||||
// be scrubbed before a fresh game spawns. Production callers must
|
// regular cancel / finish lifecycle. It is exercised by the lobby
|
||||||
// stay on the regular cancel / finish lifecycle — `DeleteGame` is
|
// integration tests.
|
||||||
// destructive and bypasses the cascade-notification machinery.
|
|
||||||
func (s *Service) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
|
func (s *Service) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
|
||||||
if err := s.deps.Store.DeleteGame(ctx, gameID); err != nil {
|
if err := s.deps.Store.DeleteGame(ctx, gameID); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -248,8 +248,8 @@ func TestEndToEndPrivateGameFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDeleteGameCascadesEverything pins the contract the dev-sandbox
|
// TestDeleteGameCascadesEverything pins the DeleteGame contract:
|
||||||
// bootstrap relies on: removing a game wipes every referencing row
|
// removing a game wipes every referencing row
|
||||||
// (memberships, applications, invites, runtime_records,
|
// (memberships, applications, invites, runtime_records,
|
||||||
// player_mappings) in a single SQL statement. Before this is wired
|
// player_mappings) in a single SQL statement. Before this is wired
|
||||||
// the developer's lobby pile up cancelled tiles between
|
// the developer's lobby pile up cancelled tiles between
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ type InsertMembershipDirectInput struct {
|
|||||||
// writes as ApproveApplication: the per-game race-name reservation
|
// writes as ApproveApplication: the per-game race-name reservation
|
||||||
// row plus the membership row, and refreshes the in-memory caches.
|
// row plus the membership row, and refreshes the in-memory caches.
|
||||||
//
|
//
|
||||||
// The method is intended for boot-time provisioning by
|
// The method is intended for trusted boot-time provisioning and
|
||||||
// `backend/internal/devsandbox` and similar trusted callers. It is
|
// integration tests; it is not exposed through any HTTP handler. The
|
||||||
// not exposed through any HTTP handler. The caller must guarantee
|
// caller must guarantee
|
||||||
// game.Status == GameStatusEnrollmentOpen — the function returns
|
// game.Status == GameStatusEnrollmentOpen — the function returns
|
||||||
// ErrConflict otherwise — and that the race-name policy and
|
// ErrConflict otherwise — and that the race-name policy and
|
||||||
// canonical-key invariants are honoured (the implementation reuses
|
// canonical-key invariants are honoured (the implementation reuses
|
||||||
@@ -30,9 +30,8 @@ type InsertMembershipDirectInput struct {
|
|||||||
// or unsuitable name still fails).
|
// or unsuitable name still fails).
|
||||||
//
|
//
|
||||||
// Idempotency: if a membership for (GameID, UserID) already exists
|
// Idempotency: if a membership for (GameID, UserID) already exists
|
||||||
// the function returns the existing row without modifying state.
|
// the function returns the existing row without modifying state, so
|
||||||
// This makes the helper safe to call on every backend boot from
|
// the helper is safe to call repeatedly.
|
||||||
// devsandbox.Bootstrap.
|
|
||||||
func (s *Service) InsertMembershipDirect(ctx context.Context, in InsertMembershipDirectInput) (Membership, error) {
|
func (s *Service) InsertMembershipDirect(ctx context.Context, in InsertMembershipDirectInput) (Membership, error) {
|
||||||
displayName, err := ValidateDisplayName(in.RaceName)
|
displayName, err := ValidateDisplayName(in.RaceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -236,9 +236,8 @@ func (s *Store) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameRecord
|
|||||||
// referencing table (memberships / applications / invites /
|
// referencing table (memberships / applications / invites /
|
||||||
// runtime_records / player_mappings — all declared with ON DELETE
|
// runtime_records / player_mappings — all declared with ON DELETE
|
||||||
// CASCADE in `00001_init.sql`). Idempotent: returns nil when no row
|
// CASCADE in `00001_init.sql`). Idempotent: returns nil when no row
|
||||||
// matches. Used by the dev-sandbox bootstrap to scrub terminal
|
// matches. A hard delete for trusted callers and integration tests;
|
||||||
// games on every backend boot so the developer's lobby never piles
|
// production lifecycle uses cancel / finish.
|
||||||
// up cancelled tiles.
|
|
||||||
func (s *Store) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
|
func (s *Store) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
|
||||||
g := table.Games
|
g := table.Games
|
||||||
stmt := g.DELETE().WHERE(g.GameID.EQ(postgres.UUID(gameID)))
|
stmt := g.DELETE().WHERE(g.GameID.EQ(postgres.UUID(gameID)))
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// Package opsstatus reads point-in-time operational signals from Postgres for
|
||||||
|
// the admin console dashboard: database reachability, per-status counts of game
|
||||||
|
// runtimes, mail deliveries, and notification routes, plus the malformed
|
||||||
|
// notification-intent count.
|
||||||
|
//
|
||||||
|
// It is a read-only projection built entirely through the go-jet query builder
|
||||||
|
// against the generated table bindings; it owns no business logic and mutates
|
||||||
|
// nothing. Richer, historical metrics are out of scope — those belong to the
|
||||||
|
// Prometheus exporters wired on `backend` and `gateway`.
|
||||||
|
package opsstatus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/postgres/jet/backend/table"
|
||||||
|
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultCollectTimeout bounds a single Collect call so a slow or wedged
|
||||||
|
// database cannot hang the dashboard request.
|
||||||
|
const defaultCollectTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
// StatusCount pairs a status value with the number of rows currently in it.
|
||||||
|
type StatusCount struct {
|
||||||
|
Status string
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot is a point-in-time view of the operational signals rendered on the
|
||||||
|
// dashboard. Errors collects per-query failures so a single failing probe
|
||||||
|
// degrades to a visible note rather than failing the whole page.
|
||||||
|
type Snapshot struct {
|
||||||
|
PostgresHealthy bool
|
||||||
|
Runtimes []StatusCount
|
||||||
|
MailDeliveries []StatusCount
|
||||||
|
NotificationRoutes []StatusCount
|
||||||
|
NotificationMalformed int64
|
||||||
|
Errors []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reader collects an operational Snapshot. The admin console depends on this
|
||||||
|
// interface so the dashboard can be tested without a database.
|
||||||
|
type Reader interface {
|
||||||
|
Collect(ctx context.Context) Snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store is the Postgres-backed Reader.
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore constructs a Store reading from db.
|
||||||
|
func NewStore(db *sql.DB) *Store {
|
||||||
|
return &Store{db: db, timeout: defaultCollectTimeout}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect gathers the dashboard signals within a bounded timeout. It never
|
||||||
|
// returns an error: a failed probe is recorded in Snapshot.Errors and the
|
||||||
|
// remaining probes still run, except that a failed Postgres ping short-circuits
|
||||||
|
// the rest (the dependent queries would only fail the same way).
|
||||||
|
func (s *Store) Collect(ctx context.Context) Snapshot {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, s.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var snap Snapshot
|
||||||
|
|
||||||
|
if err := s.db.PingContext(ctx); err != nil {
|
||||||
|
snap.Errors = append(snap.Errors, fmt.Sprintf("postgres ping: %v", err))
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
snap.PostgresHealthy = true
|
||||||
|
|
||||||
|
if counts, err := s.statusCounts(ctx, table.RuntimeRecords.Status, table.RuntimeRecords); err != nil {
|
||||||
|
snap.Errors = append(snap.Errors, fmt.Sprintf("runtime status counts: %v", err))
|
||||||
|
} else {
|
||||||
|
snap.Runtimes = counts
|
||||||
|
}
|
||||||
|
|
||||||
|
if counts, err := s.statusCounts(ctx, table.MailDeliveries.Status, table.MailDeliveries); err != nil {
|
||||||
|
snap.Errors = append(snap.Errors, fmt.Sprintf("mail delivery counts: %v", err))
|
||||||
|
} else {
|
||||||
|
snap.MailDeliveries = counts
|
||||||
|
}
|
||||||
|
|
||||||
|
if counts, err := s.statusCounts(ctx, table.NotificationRoutes.Status, table.NotificationRoutes); err != nil {
|
||||||
|
snap.Errors = append(snap.Errors, fmt.Sprintf("notification route counts: %v", err))
|
||||||
|
} else {
|
||||||
|
snap.NotificationRoutes = counts
|
||||||
|
}
|
||||||
|
|
||||||
|
if n, err := s.countAll(ctx, table.NotificationMalformedIntents); err != nil {
|
||||||
|
snap.Errors = append(snap.Errors, fmt.Sprintf("malformed notification count: %v", err))
|
||||||
|
} else {
|
||||||
|
snap.NotificationMalformed = n
|
||||||
|
}
|
||||||
|
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusCounts runs `SELECT status, COUNT(*) FROM <from> GROUP BY status`
|
||||||
|
// through jet and returns the rows ordered by status.
|
||||||
|
func (s *Store) statusCounts(ctx context.Context, status postgres.ColumnString, from postgres.ReadableTable) ([]StatusCount, error) {
|
||||||
|
stmt := postgres.SELECT(
|
||||||
|
status.AS("status_count.status"),
|
||||||
|
postgres.COUNT(postgres.STAR).AS("status_count.count"),
|
||||||
|
).FROM(from).GROUP_BY(status).ORDER_BY(status.ASC())
|
||||||
|
|
||||||
|
var rows []struct {
|
||||||
|
Status string `alias:"status_count.status"`
|
||||||
|
Count int64 `alias:"status_count.count"`
|
||||||
|
}
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]StatusCount, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
out[i] = StatusCount{Status: row.Status, Count: row.Count}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// countAll runs `SELECT COUNT(*) FROM <from>` through jet.
|
||||||
|
func (s *Store) countAll(ctx context.Context, from postgres.ReadableTable) (int64, error) {
|
||||||
|
stmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).FROM(from)
|
||||||
|
|
||||||
|
var row struct {
|
||||||
|
Count int64 `alias:"count"`
|
||||||
|
}
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return row.Count, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package opsstatus_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/mail"
|
||||||
|
"galaxy/backend/internal/opsstatus"
|
||||||
|
backendpg "galaxy/backend/internal/postgres"
|
||||||
|
pgshared "galaxy/postgres"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
testcontainers "github.com/testcontainers/testcontainers-go"
|
||||||
|
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||||
|
"github.com/testcontainers/testcontainers-go/wait"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
pgImage = "postgres:16-alpine"
|
||||||
|
pgUser = "galaxy"
|
||||||
|
pgPassword = "galaxy"
|
||||||
|
pgDatabase = "galaxy_backend"
|
||||||
|
pgSchema = "backend"
|
||||||
|
pgStartup = 90 * time.Second
|
||||||
|
pgOpTO = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// startPostgres mirrors the per-package scaffolding used by the other store
|
||||||
|
// tests: spin up Postgres, apply migrations, return *sql.DB.
|
||||||
|
func startPostgres(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
pgContainer, err := tcpostgres.Run(ctx, pgImage,
|
||||||
|
tcpostgres.WithDatabase(pgDatabase),
|
||||||
|
tcpostgres.WithUsername(pgUser),
|
||||||
|
tcpostgres.WithPassword(pgPassword),
|
||||||
|
testcontainers.WithWaitStrategy(
|
||||||
|
wait.ForLog("database system is ready to accept connections").
|
||||||
|
WithOccurrence(2).
|
||||||
|
WithStartupTimeout(pgStartup),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("postgres testcontainer unavailable, skipping: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil {
|
||||||
|
t.Errorf("terminate postgres container: %v", termErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connection string: %v", err)
|
||||||
|
}
|
||||||
|
scopedDSN, err := dsnWithSearchPath(baseDSN, pgSchema)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scope dsn: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := pgshared.DefaultConfig()
|
||||||
|
cfg.PrimaryDSN = scopedDSN
|
||||||
|
cfg.OperationTimeout = pgOpTO
|
||||||
|
|
||||||
|
db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open primary: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil {
|
||||||
|
t.Fatalf("ping: %v", err)
|
||||||
|
}
|
||||||
|
if err := backendpg.ApplyMigrations(ctx, db); err != nil {
|
||||||
|
t.Fatalf("apply migrations: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func dsnWithSearchPath(baseDSN, schema string) (string, error) {
|
||||||
|
parsed, err := url.Parse(baseDSN)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
values := parsed.Query()
|
||||||
|
values.Set("search_path", schema)
|
||||||
|
if values.Get("sslmode") == "" {
|
||||||
|
values.Set("sslmode", "disable")
|
||||||
|
}
|
||||||
|
parsed.RawQuery = values.Encode()
|
||||||
|
return parsed.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreCollect(t *testing.T) {
|
||||||
|
db := startPostgres(t)
|
||||||
|
store := opsstatus.NewStore(db)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Empty schema: queries must execute cleanly with zero counts.
|
||||||
|
empty := store.Collect(ctx)
|
||||||
|
if !empty.PostgresHealthy {
|
||||||
|
t.Fatal("PostgresHealthy must be true against a reachable database")
|
||||||
|
}
|
||||||
|
if len(empty.Errors) != 0 {
|
||||||
|
t.Fatalf("unexpected collection errors: %v", empty.Errors)
|
||||||
|
}
|
||||||
|
if got := totalCount(empty.MailDeliveries); got != 0 {
|
||||||
|
t.Fatalf("mail deliveries total = %d, want 0", got)
|
||||||
|
}
|
||||||
|
if len(empty.Runtimes) != 0 || len(empty.NotificationRoutes) != 0 {
|
||||||
|
t.Fatalf("expected empty status slices, got runtimes=%v routes=%v", empty.Runtimes, empty.NotificationRoutes)
|
||||||
|
}
|
||||||
|
if empty.NotificationMalformed != 0 {
|
||||||
|
t.Fatalf("malformed notifications = %d, want 0", empty.NotificationMalformed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue one mail delivery and confirm the GROUP BY count reflects it.
|
||||||
|
mailStore := mail.NewStore(db)
|
||||||
|
inserted, err := mailStore.InsertEnqueue(ctx, mail.EnqueueArgs{
|
||||||
|
DeliveryID: uuid.New(),
|
||||||
|
TemplateID: mail.TemplateLoginCode,
|
||||||
|
IdempotencyKey: uuid.NewString(),
|
||||||
|
Recipients: []string{"ops@example.test"},
|
||||||
|
ContentType: "text/plain",
|
||||||
|
Subject: "hello",
|
||||||
|
Body: []byte("hi"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert mail delivery: %v", err)
|
||||||
|
}
|
||||||
|
if !inserted {
|
||||||
|
t.Fatal("expected the delivery to be inserted")
|
||||||
|
}
|
||||||
|
|
||||||
|
after := store.Collect(ctx)
|
||||||
|
if len(after.Errors) != 0 {
|
||||||
|
t.Fatalf("unexpected collection errors after insert: %v", after.Errors)
|
||||||
|
}
|
||||||
|
if got := totalCount(after.MailDeliveries); got != 1 {
|
||||||
|
t.Fatalf("mail deliveries total after insert = %d, want 1 (statuses: %v)", got, after.MailDeliveries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalCount(counts []opsstatus.StatusCount) int64 {
|
||||||
|
var total int64
|
||||||
|
for _, c := range counts {
|
||||||
|
total += c.Count
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/adminconsole"
|
||||||
|
"galaxy/backend/internal/opsstatus"
|
||||||
|
"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
|
||||||
|
monitor opsstatus.Reader
|
||||||
|
ready func() bool
|
||||||
|
users UserAdmin
|
||||||
|
games GameAdmin
|
||||||
|
runtime RuntimeAdmin
|
||||||
|
engineVersions EngineVersionAdmin
|
||||||
|
operators OperatorAdmin
|
||||||
|
mail MailAdmin
|
||||||
|
notifications NotificationAdmin
|
||||||
|
diplomail DiplomailAdmin
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminConsoleDeps bundles the collaborators for the operator console. Every
|
||||||
|
// field is optional: a nil Renderer or CSRF falls back to the embedded default
|
||||||
|
// templates and a per-process random key; a nil Monitor renders the dashboard
|
||||||
|
// without the monitoring panels; a nil Ready reports backend readiness as not
|
||||||
|
// ready; a nil Logger falls back to zap.NewNop.
|
||||||
|
type AdminConsoleDeps struct {
|
||||||
|
Renderer *adminconsole.Renderer
|
||||||
|
CSRF *adminconsole.CSRF
|
||||||
|
Monitor opsstatus.Reader
|
||||||
|
Ready func() bool
|
||||||
|
Users UserAdmin
|
||||||
|
Games GameAdmin
|
||||||
|
Runtime RuntimeAdmin
|
||||||
|
EngineVersions EngineVersionAdmin
|
||||||
|
Operators OperatorAdmin
|
||||||
|
Mail MailAdmin
|
||||||
|
Notifications NotificationAdmin
|
||||||
|
Diplomail DiplomailAdmin
|
||||||
|
Logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminConsoleHandlers constructs the console handler set from deps. It
|
||||||
|
// panics only on conditions that are unrecoverable at startup (template parse
|
||||||
|
// failure or crypto/rand failure), both of which indicate a broken build or
|
||||||
|
// host rather than a runtime input.
|
||||||
|
func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers {
|
||||||
|
logger := deps.Logger
|
||||||
|
if logger == nil {
|
||||||
|
logger = zap.NewNop()
|
||||||
|
}
|
||||||
|
renderer := deps.Renderer
|
||||||
|
if renderer == nil {
|
||||||
|
renderer = adminconsole.MustNewRenderer()
|
||||||
|
}
|
||||||
|
csrf := deps.CSRF
|
||||||
|
if csrf == nil {
|
||||||
|
generated, err := adminconsole.NewRandomCSRF()
|
||||||
|
if err != nil {
|
||||||
|
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))),
|
||||||
|
monitor: deps.Monitor,
|
||||||
|
ready: deps.Ready,
|
||||||
|
users: deps.Users,
|
||||||
|
games: deps.Games,
|
||||||
|
runtime: deps.Runtime,
|
||||||
|
engineVersions: deps.EngineVersions,
|
||||||
|
operators: deps.Operators,
|
||||||
|
mail: deps.Mail,
|
||||||
|
notifications: deps.Notifications,
|
||||||
|
diplomail: deps.Diplomail,
|
||||||
|
logger: logger.Named("http.admin.console"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard renders the console landing page (GET /_gm and GET /_gm/),
|
||||||
|
// including the monitoring panels when an ops-status reader is wired.
|
||||||
|
func (h *AdminConsoleHandlers) Dashboard() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
data := adminconsole.DashboardData{}
|
||||||
|
if h.ready != nil {
|
||||||
|
data.BackendReady = h.ready()
|
||||||
|
}
|
||||||
|
if h.monitor != nil {
|
||||||
|
data.MonitorAvailable = true
|
||||||
|
snapshot := h.monitor.Collect(c.Request.Context())
|
||||||
|
data.PostgresHealthy = snapshot.PostgresHealthy
|
||||||
|
data.Runtimes = toViewCounts(snapshot.Runtimes)
|
||||||
|
data.MailDeliveries = toViewCounts(snapshot.MailDeliveries)
|
||||||
|
data.NotificationRoutes = toViewCounts(snapshot.NotificationRoutes)
|
||||||
|
data.NotificationMalformed = snapshot.NotificationMalformed
|
||||||
|
data.Errors = snapshot.Errors
|
||||||
|
}
|
||||||
|
h.render(c, http.StatusOK, "dashboard", "dashboard", "Dashboard", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toViewCounts maps ops-status counts to the console's view-layer counts.
|
||||||
|
func toViewCounts(in []opsstatus.StatusCount) []adminconsole.StatusCount {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]adminconsole.StatusCount, len(in))
|
||||||
|
for i, sc := range in {
|
||||||
|
out[i] = adminconsole.StatusCount{Status: sc.Status, Count: sc.Count}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asset serves the embedded console static assets under `/_gm/assets/`.
|
||||||
|
func (h *AdminConsoleHandlers) Asset() gin.HandlerFunc {
|
||||||
|
return gin.WrapH(h.assets)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderMessage renders the generic message page (not-found, validation, or
|
||||||
|
// operation-failure notices). class selects the CSS styling and backHref, when
|
||||||
|
// non-empty, adds a back link.
|
||||||
|
func (h *AdminConsoleHandlers) renderMessage(c *gin.Context, status int, activeNav, title, message, class, backHref string) {
|
||||||
|
h.render(c, status, "message", activeNav, title, adminconsole.MessageData{
|
||||||
|
Message: message,
|
||||||
|
Class: class,
|
||||||
|
BackHref: backHref,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSafeHTTPMethod reports whether method is a read-only HTTP method that the
|
||||||
|
// CSRF guard may let through without a token.
|
||||||
|
func isSafeHTTPMethod(method string) bool {
|
||||||
|
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,423 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/adminconsole"
|
||||||
|
"galaxy/backend/internal/lobby"
|
||||||
|
"galaxy/backend/internal/runtime"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GameAdmin is the subset of the lobby service the console uses for games.
|
||||||
|
type GameAdmin interface {
|
||||||
|
ListAdminGames(ctx context.Context, page, pageSize int) (lobby.GamePage, error)
|
||||||
|
GetGame(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error)
|
||||||
|
CreateGame(ctx context.Context, input lobby.CreateGameInput) (lobby.GameRecord, error)
|
||||||
|
AdminForceStart(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error)
|
||||||
|
AdminForceStop(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error)
|
||||||
|
AdminBanMember(ctx context.Context, gameID, userID uuid.UUID, reason string) (lobby.Membership, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimeAdmin is the subset of the runtime service the console uses.
|
||||||
|
type RuntimeAdmin interface {
|
||||||
|
GetRuntime(ctx context.Context, gameID uuid.UUID) (runtime.RuntimeRecord, error)
|
||||||
|
AdminRestart(ctx context.Context, gameID uuid.UUID) (runtime.OperationLog, error)
|
||||||
|
AdminPatch(ctx context.Context, gameID uuid.UUID, targetVersion string) (runtime.OperationLog, error)
|
||||||
|
AdminForceNextTurn(ctx context.Context, gameID uuid.UUID) (runtime.OperationLog, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngineVersionAdmin is the subset of the engine-version service the console uses.
|
||||||
|
type EngineVersionAdmin interface {
|
||||||
|
List(ctx context.Context) ([]runtime.EngineVersion, error)
|
||||||
|
Register(ctx context.Context, in runtime.RegisterInput) (runtime.EngineVersion, error)
|
||||||
|
Disable(ctx context.Context, version string) (runtime.EngineVersion, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GamesList renders GET /_gm/games.
|
||||||
|
func (h *AdminConsoleHandlers) GamesList() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.games == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page := parsePositiveQueryInt(c.Query("page"), 1)
|
||||||
|
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
||||||
|
result, err := h.games.ListAdminGames(c.Request.Context(), page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin console: list games", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "games", "Games", "Failed to load games.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(c, http.StatusOK, "games", "games", "Games", toGamesListData(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameCreate handles POST /_gm/games — create a public game.
|
||||||
|
func (h *AdminConsoleHandlers) GameCreate() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.games == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enrollmentEndsAt, err := parseConsoleDateTime(c.PostForm("enrollment_ends_at"))
|
||||||
|
if err != nil {
|
||||||
|
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "Enrollment end must be a valid date/time.", "bad", "/_gm/games")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
game, err := h.games.CreateGame(c.Request.Context(), lobby.CreateGameInput{
|
||||||
|
OwnerUserID: nil,
|
||||||
|
Visibility: lobby.VisibilityPublic,
|
||||||
|
GameName: strings.TrimSpace(c.PostForm("game_name")),
|
||||||
|
Description: strings.TrimSpace(c.PostForm("description")),
|
||||||
|
MinPlayers: formInt32(c, "min_players"),
|
||||||
|
MaxPlayers: formInt32(c, "max_players"),
|
||||||
|
StartGapHours: formInt32(c, "start_gap_hours"),
|
||||||
|
StartGapPlayers: formInt32(c, "start_gap_players"),
|
||||||
|
EnrollmentEndsAt: enrollmentEndsAt,
|
||||||
|
TurnSchedule: strings.TrimSpace(c.PostForm("turn_schedule")),
|
||||||
|
TargetEngineVersion: strings.TrimSpace(c.PostForm("target_engine_version")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, lobby.ErrInvalidInput) {
|
||||||
|
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "The game could not be created: check the fields.", "bad", "/_gm/games")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("admin console: create game", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "games", "Create failed", "Failed to create the game.", "bad", "/_gm/games")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, "/_gm/games/"+game.GameID.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameDetail renders GET /_gm/games/:game_id with the runtime snapshot.
|
||||||
|
func (h *AdminConsoleHandlers) GameDetail() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.games == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gameID, ok := parseGameIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
game, err := h.games.GetGame(c.Request.Context(), gameID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, lobby.ErrNotFound) {
|
||||||
|
h.renderMessage(c, http.StatusNotFound, "games", "Game not found", "No such game.", "bad", "/_gm/games")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("admin console: get game", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "games", "Games", "Failed to load the game.", "bad", "/_gm/games")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var runtimeRecord *runtime.RuntimeRecord
|
||||||
|
if h.runtime != nil {
|
||||||
|
if record, rtErr := h.runtime.GetRuntime(c.Request.Context(), gameID); rtErr == nil {
|
||||||
|
runtimeRecord = &record
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.render(c, http.StatusOK, "game_detail", "games", game.GameName, toGameDetailData(game, runtimeRecord))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameForceStart handles POST /_gm/games/:game_id/force-start.
|
||||||
|
func (h *AdminConsoleHandlers) GameForceStart() gin.HandlerFunc {
|
||||||
|
return h.gameAction("force-start", func(ctx context.Context, gameID uuid.UUID) error {
|
||||||
|
_, err := h.games.AdminForceStart(ctx, gameID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameForceStop handles POST /_gm/games/:game_id/force-stop.
|
||||||
|
func (h *AdminConsoleHandlers) GameForceStop() gin.HandlerFunc {
|
||||||
|
return h.gameAction("force-stop", func(ctx context.Context, gameID uuid.UUID) error {
|
||||||
|
_, err := h.games.AdminForceStop(ctx, gameID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GameBanMember handles POST /_gm/games/:game_id/ban-member.
|
||||||
|
func (h *AdminConsoleHandlers) GameBanMember() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.games == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gameID, ok := parseGameIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := "/_gm/games/" + gameID.String()
|
||||||
|
userID, err := uuid.Parse(strings.TrimSpace(c.PostForm("user_id")))
|
||||||
|
if err != nil {
|
||||||
|
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "User ID must be a valid UUID.", "bad", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := h.games.AdminBanMember(c.Request.Context(), gameID, userID, strings.TrimSpace(c.PostForm("reason"))); err != nil {
|
||||||
|
h.logger.Error("admin console: ban member", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "games", "Ban failed", "Failed to ban the member.", "bad", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimeRestart handles POST /_gm/games/:game_id/runtime/restart.
|
||||||
|
func (h *AdminConsoleHandlers) RuntimeRestart() gin.HandlerFunc {
|
||||||
|
return h.runtimeAction("restart", func(ctx context.Context, gameID uuid.UUID) error {
|
||||||
|
_, err := h.runtime.AdminRestart(ctx, gameID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimeForceNextTurn handles POST /_gm/games/:game_id/runtime/force-next-turn.
|
||||||
|
func (h *AdminConsoleHandlers) RuntimeForceNextTurn() gin.HandlerFunc {
|
||||||
|
return h.runtimeAction("force-next-turn", func(ctx context.Context, gameID uuid.UUID) error {
|
||||||
|
_, err := h.runtime.AdminForceNextTurn(ctx, gameID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimePatch handles POST /_gm/games/:game_id/runtime/patch.
|
||||||
|
func (h *AdminConsoleHandlers) RuntimePatch() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.runtime == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Runtime", "Runtime administration is not available.", "bad", "/_gm/games")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gameID, ok := parseGameIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := "/_gm/games/" + gameID.String()
|
||||||
|
target := strings.TrimSpace(c.PostForm("target_version"))
|
||||||
|
if target == "" {
|
||||||
|
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "A target version is required.", "bad", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := h.runtime.AdminPatch(c.Request.Context(), gameID, target); err != nil {
|
||||||
|
h.logger.Error("admin console: runtime patch", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "games", "Patch failed", "Failed to patch the runtime.", "bad", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gameAction is the shared shape for game-state POST actions that take only the
|
||||||
|
// game id and redirect back to the detail page.
|
||||||
|
func (h *AdminConsoleHandlers) gameAction(label string, run func(context.Context, uuid.UUID) error) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.games == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gameID, ok := parseGameIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := "/_gm/games/" + gameID.String()
|
||||||
|
if err := run(c.Request.Context(), gameID); err != nil {
|
||||||
|
h.logger.Error("admin console: game "+label, zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "games", "Action failed", "The "+label+" action failed.", "bad", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runtimeAction is the shared shape for runtime POST actions that take only the
|
||||||
|
// game id and redirect back to the detail page.
|
||||||
|
func (h *AdminConsoleHandlers) runtimeAction(label string, run func(context.Context, uuid.UUID) error) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.runtime == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Runtime", "Runtime administration is not available.", "bad", "/_gm/games")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gameID, ok := parseGameIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := "/_gm/games/" + gameID.String()
|
||||||
|
if err := run(c.Request.Context(), gameID); err != nil {
|
||||||
|
h.logger.Error("admin console: runtime "+label, zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "games", "Action failed", "The runtime "+label+" action failed.", "bad", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngineVersionsList renders GET /_gm/engine-versions.
|
||||||
|
func (h *AdminConsoleHandlers) EngineVersionsList() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.engineVersions == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.engineVersions.List(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin console: list engine versions", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Engine versions", "Failed to load engine versions.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(c, http.StatusOK, "engine_versions", "games", "Engine versions", toEngineVersionsData(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngineVersionRegister handles POST /_gm/engine-versions.
|
||||||
|
func (h *AdminConsoleHandlers) EngineVersionRegister() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.engineVersions == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enabled := c.PostForm("enabled") == "true"
|
||||||
|
_, err := h.engineVersions.Register(c.Request.Context(), runtime.RegisterInput{
|
||||||
|
Version: strings.TrimSpace(c.PostForm("version")),
|
||||||
|
ImageRef: strings.TrimSpace(c.PostForm("image_ref")),
|
||||||
|
Enabled: &enabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, runtime.ErrInvalidInput) || errors.Is(err, runtime.ErrEngineVersionTaken) {
|
||||||
|
h.renderMessage(c, http.StatusBadRequest, "engine-versions", "Invalid input", "The version could not be registered (invalid semver, missing image, or duplicate).", "bad", "/_gm/engine-versions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("admin console: register engine version", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Register failed", "Failed to register the engine version.", "bad", "/_gm/engine-versions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, "/_gm/engine-versions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngineVersionDisable handles POST /_gm/engine-versions/:version/disable.
|
||||||
|
func (h *AdminConsoleHandlers) EngineVersionDisable() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.engineVersions == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
version := strings.TrimSpace(c.Param("version"))
|
||||||
|
if _, err := h.engineVersions.Disable(c.Request.Context(), version); err != nil {
|
||||||
|
h.logger.Error("admin console: disable engine version", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Disable failed", "Failed to disable the engine version.", "bad", "/_gm/engine-versions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, "/_gm/engine-versions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formInt32 reads a non-negative int32 form field, defaulting to 0.
|
||||||
|
func formInt32(c *gin.Context, name string) int32 {
|
||||||
|
parsed, err := strconv.Atoi(strings.TrimSpace(c.PostForm(name)))
|
||||||
|
if err != nil || parsed < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int32(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseConsoleDateTime parses the value of an <input type="datetime-local">
|
||||||
|
// (or an RFC 3339 timestamp) as UTC.
|
||||||
|
func parseConsoleDateTime(raw string) (time.Time, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05", time.RFC3339} {
|
||||||
|
if t, err := time.ParseInLocation(layout, raw, time.UTC); err == nil {
|
||||||
|
return t.UTC(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, errors.New("invalid date/time")
|
||||||
|
}
|
||||||
|
|
||||||
|
// toGamesListData maps a game page into the games list view model.
|
||||||
|
func toGamesListData(page lobby.GamePage) adminconsole.GamesListData {
|
||||||
|
data := adminconsole.GamesListData{
|
||||||
|
Items: make([]adminconsole.GameRow, 0, len(page.Items)),
|
||||||
|
Page: page.Page,
|
||||||
|
PageSize: page.PageSize,
|
||||||
|
Total: page.Total,
|
||||||
|
PrevPage: page.Page - 1,
|
||||||
|
NextPage: page.Page + 1,
|
||||||
|
HasPrev: page.Page > 1,
|
||||||
|
HasNext: page.Page*page.PageSize < page.Total,
|
||||||
|
}
|
||||||
|
for _, game := range page.Items {
|
||||||
|
data.Items = append(data.Items, adminconsole.GameRow{
|
||||||
|
GameID: game.GameID.String(),
|
||||||
|
GameName: game.GameName,
|
||||||
|
Visibility: game.Visibility,
|
||||||
|
Status: game.Status,
|
||||||
|
Owner: ownerLabel(game.OwnerUserID),
|
||||||
|
Players: strconv.Itoa(int(game.MinPlayers)) + "–" + strconv.Itoa(int(game.MaxPlayers)),
|
||||||
|
TurnSchedule: game.TurnSchedule,
|
||||||
|
CreatedAt: fmtConsoleTime(game.CreatedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// toGameDetailData maps a game record and optional runtime record into the
|
||||||
|
// detail view model.
|
||||||
|
func toGameDetailData(game lobby.GameRecord, rec *runtime.RuntimeRecord) adminconsole.GameDetailData {
|
||||||
|
data := adminconsole.GameDetailData{
|
||||||
|
GameID: game.GameID.String(),
|
||||||
|
GameName: game.GameName,
|
||||||
|
Description: game.Description,
|
||||||
|
Visibility: game.Visibility,
|
||||||
|
Status: game.Status,
|
||||||
|
Owner: ownerLabel(game.OwnerUserID),
|
||||||
|
MinPlayers: game.MinPlayers,
|
||||||
|
MaxPlayers: game.MaxPlayers,
|
||||||
|
StartGapHours: game.StartGapHours,
|
||||||
|
StartGapPlayers: game.StartGapPlayers,
|
||||||
|
TurnSchedule: game.TurnSchedule,
|
||||||
|
TargetEngineVersion: game.TargetEngineVersion,
|
||||||
|
EnrollmentEndsAt: fmtConsoleTime(game.EnrollmentEndsAt),
|
||||||
|
CreatedAt: fmtConsoleTime(game.CreatedAt),
|
||||||
|
StartedAt: fmtConsoleTimePtr(game.StartedAt),
|
||||||
|
FinishedAt: fmtConsoleTimePtr(game.FinishedAt),
|
||||||
|
}
|
||||||
|
if rec != nil {
|
||||||
|
data.HasRuntime = true
|
||||||
|
data.RuntimeStatus = rec.Status
|
||||||
|
data.CurrentEngineVersion = rec.CurrentEngineVersion
|
||||||
|
data.EngineHealth = rec.EngineHealth
|
||||||
|
data.CurrentTurn = rec.CurrentTurn
|
||||||
|
data.NextGenerationAt = fmtConsoleTimePtr(rec.NextGenerationAt)
|
||||||
|
data.Paused = rec.Paused
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// toEngineVersionsData maps engine versions into the registry view model.
|
||||||
|
func toEngineVersionsData(items []runtime.EngineVersion) adminconsole.EngineVersionsData {
|
||||||
|
data := adminconsole.EngineVersionsData{Items: make([]adminconsole.EngineVersionRow, 0, len(items))}
|
||||||
|
for _, v := range items {
|
||||||
|
data.Items = append(data.Items, adminconsole.EngineVersionRow{
|
||||||
|
Version: v.Version,
|
||||||
|
ImageRef: v.ImageRef,
|
||||||
|
Enabled: v.Enabled,
|
||||||
|
CreatedAt: fmtConsoleTime(v.CreatedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownerLabel renders an optional owner id; public games have no owner.
|
||||||
|
func ownerLabel(ownerID *uuid.UUID) string {
|
||||||
|
if ownerID == nil {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return ownerID.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/adminconsole"
|
||||||
|
"galaxy/backend/internal/lobby"
|
||||||
|
"galaxy/backend/internal/runtime"
|
||||||
|
"galaxy/backend/internal/server/middleware/basicauth"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeGameAdmin struct {
|
||||||
|
page lobby.GamePage
|
||||||
|
game lobby.GameRecord
|
||||||
|
getErr error
|
||||||
|
created lobby.CreateGameInput
|
||||||
|
|
||||||
|
createCalls int
|
||||||
|
forceStartCalls int
|
||||||
|
forceStopCalls int
|
||||||
|
banCalls int
|
||||||
|
lastBanUser uuid.UUID
|
||||||
|
lastBanReason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeGameAdmin) ListAdminGames(context.Context, int, int) (lobby.GamePage, error) {
|
||||||
|
return f.page, nil
|
||||||
|
}
|
||||||
|
func (f *fakeGameAdmin) GetGame(context.Context, uuid.UUID) (lobby.GameRecord, error) {
|
||||||
|
return f.game, f.getErr
|
||||||
|
}
|
||||||
|
func (f *fakeGameAdmin) CreateGame(_ context.Context, in lobby.CreateGameInput) (lobby.GameRecord, error) {
|
||||||
|
f.createCalls++
|
||||||
|
f.created = in
|
||||||
|
return f.game, nil
|
||||||
|
}
|
||||||
|
func (f *fakeGameAdmin) AdminForceStart(context.Context, uuid.UUID) (lobby.GameRecord, error) {
|
||||||
|
f.forceStartCalls++
|
||||||
|
return f.game, nil
|
||||||
|
}
|
||||||
|
func (f *fakeGameAdmin) AdminForceStop(context.Context, uuid.UUID) (lobby.GameRecord, error) {
|
||||||
|
f.forceStopCalls++
|
||||||
|
return f.game, nil
|
||||||
|
}
|
||||||
|
func (f *fakeGameAdmin) AdminBanMember(_ context.Context, _, userID uuid.UUID, reason string) (lobby.Membership, error) {
|
||||||
|
f.banCalls++
|
||||||
|
f.lastBanUser = userID
|
||||||
|
f.lastBanReason = reason
|
||||||
|
return lobby.Membership{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeRuntimeAdmin struct {
|
||||||
|
record runtime.RuntimeRecord
|
||||||
|
getErr error
|
||||||
|
restartCalls int
|
||||||
|
forceNextCalls int
|
||||||
|
patchCalls int
|
||||||
|
lastPatchVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRuntimeAdmin) GetRuntime(context.Context, uuid.UUID) (runtime.RuntimeRecord, error) {
|
||||||
|
return f.record, f.getErr
|
||||||
|
}
|
||||||
|
func (f *fakeRuntimeAdmin) AdminRestart(context.Context, uuid.UUID) (runtime.OperationLog, error) {
|
||||||
|
f.restartCalls++
|
||||||
|
return runtime.OperationLog{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeRuntimeAdmin) AdminPatch(_ context.Context, _ uuid.UUID, target string) (runtime.OperationLog, error) {
|
||||||
|
f.patchCalls++
|
||||||
|
f.lastPatchVersion = target
|
||||||
|
return runtime.OperationLog{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeRuntimeAdmin) AdminForceNextTurn(context.Context, uuid.UUID) (runtime.OperationLog, error) {
|
||||||
|
f.forceNextCalls++
|
||||||
|
return runtime.OperationLog{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeEngineVersionAdmin struct {
|
||||||
|
list []runtime.EngineVersion
|
||||||
|
registered runtime.RegisterInput
|
||||||
|
registerCalls int
|
||||||
|
disableCalls int
|
||||||
|
lastDisabled string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeEngineVersionAdmin) List(context.Context) ([]runtime.EngineVersion, error) {
|
||||||
|
return f.list, nil
|
||||||
|
}
|
||||||
|
func (f *fakeEngineVersionAdmin) Register(_ context.Context, in runtime.RegisterInput) (runtime.EngineVersion, error) {
|
||||||
|
f.registerCalls++
|
||||||
|
f.registered = in
|
||||||
|
return runtime.EngineVersion{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeEngineVersionAdmin) Disable(_ context.Context, version string) (runtime.EngineVersion, error) {
|
||||||
|
f.disableCalls++
|
||||||
|
f.lastDisabled = version
|
||||||
|
return runtime.EngineVersion{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGamesConsoleRouter(t *testing.T, games GameAdmin, rt RuntimeAdmin, ev EngineVersionAdmin) (http.Handler, *adminconsole.CSRF) {
|
||||||
|
t.Helper()
|
||||||
|
csrf := adminconsole.NewCSRF([]byte("test-key"))
|
||||||
|
handler, err := NewRouter(RouterDependencies{
|
||||||
|
Logger: zap.NewNop(),
|
||||||
|
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
||||||
|
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{
|
||||||
|
CSRF: csrf, Games: games, Runtime: rt, EngineVersions: ev,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRouter: %v", err)
|
||||||
|
}
|
||||||
|
return handler, csrf
|
||||||
|
}
|
||||||
|
|
||||||
|
func consoleGet(t *testing.T, router http.Handler, path string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
req.SetBasicAuth("ops", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func consolePost(t *testing.T, router http.Handler, path, form string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan"+path, strings.NewReader(form))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Origin", "https://galaxy.lan")
|
||||||
|
req.SetBasicAuth("ops", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleGamesList(t *testing.T) {
|
||||||
|
games := &fakeGameAdmin{page: lobby.GamePage{
|
||||||
|
Items: []lobby.GameRecord{{GameID: uuid.New(), GameName: "Nova", Visibility: "public", Status: "enrollment_open"}},
|
||||||
|
Page: 1, PageSize: 50, Total: 1,
|
||||||
|
}}
|
||||||
|
router, _ := newGamesConsoleRouter(t, games, nil, nil)
|
||||||
|
|
||||||
|
rec := consoleGet(t, router, "/_gm/games")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
for _, want := range []string{"Nova", "public", "enrollment_open", "Create public game"} {
|
||||||
|
if !strings.Contains(rec.Body.String(), want) {
|
||||||
|
t.Errorf("games list missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleGameDetailWithRuntime(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id, GameName: "Nova", Status: "running"}}
|
||||||
|
rt := &fakeRuntimeAdmin{record: runtime.RuntimeRecord{GameID: id, Status: "running", CurrentEngineVersion: "0.1.0", CurrentTurn: 7}}
|
||||||
|
router, csrf := newGamesConsoleRouter(t, games, rt, nil)
|
||||||
|
|
||||||
|
rec := consoleGet(t, router, "/_gm/games/"+id.String())
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
for _, want := range []string{"Nova", "Force start", "Force stop", "0.1.0", "Patch", "Ban member", csrf.Token("ops")} {
|
||||||
|
if !strings.Contains(rec.Body.String(), want) {
|
||||||
|
t.Errorf("game detail missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleGameDetailNoRuntime(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id, GameName: "Nova"}}
|
||||||
|
rt := &fakeRuntimeAdmin{getErr: errors.New("not found")}
|
||||||
|
router, _ := newGamesConsoleRouter(t, games, rt, nil)
|
||||||
|
|
||||||
|
rec := consoleGet(t, router, "/_gm/games/"+id.String())
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "No runtime record") {
|
||||||
|
t.Error("expected a no-runtime note")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleGameCreate(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
|
||||||
|
router, csrf := newGamesConsoleRouter(t, games, nil, nil)
|
||||||
|
|
||||||
|
form := "_csrf=" + csrf.Token("ops") +
|
||||||
|
"&game_name=Nova&description=d&min_players=2&max_players=8&start_gap_hours=0&start_gap_players=0" +
|
||||||
|
"&enrollment_ends_at=2030-01-02T15:04&turn_schedule=@every+24h&target_engine_version=0.1.0"
|
||||||
|
rec := consolePost(t, router, "/_gm/games", form)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Location"); got != "/_gm/games/"+id.String() {
|
||||||
|
t.Errorf("redirect = %q, want detail page", got)
|
||||||
|
}
|
||||||
|
if games.createCalls != 1 {
|
||||||
|
t.Fatalf("CreateGame called %d times, want 1", games.createCalls)
|
||||||
|
}
|
||||||
|
if games.created.Visibility != lobby.VisibilityPublic {
|
||||||
|
t.Errorf("visibility = %q, want public", games.created.Visibility)
|
||||||
|
}
|
||||||
|
if games.created.GameName != "Nova" {
|
||||||
|
t.Errorf("game name = %q", games.created.GameName)
|
||||||
|
}
|
||||||
|
if games.created.EnrollmentEndsAt.Year() != 2030 {
|
||||||
|
t.Errorf("enrollment year = %d, want 2030", games.created.EnrollmentEndsAt.Year())
|
||||||
|
}
|
||||||
|
if games.created.OwnerUserID != nil {
|
||||||
|
t.Error("public game must have a nil owner")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleGameForceStart(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
|
||||||
|
router, csrf := newGamesConsoleRouter(t, games, nil, nil)
|
||||||
|
|
||||||
|
rec := consolePost(t, router, "/_gm/games/"+id.String()+"/force-start", "_csrf="+csrf.Token("ops"))
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
if games.forceStartCalls != 1 {
|
||||||
|
t.Errorf("AdminForceStart called %d times, want 1", games.forceStartCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleGameForceStartRejectsBadCSRF(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
|
||||||
|
router, _ := newGamesConsoleRouter(t, games, nil, nil)
|
||||||
|
|
||||||
|
rec := consolePost(t, router, "/_gm/games/"+id.String()+"/force-start", "")
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403", rec.Code)
|
||||||
|
}
|
||||||
|
if games.forceStartCalls != 0 {
|
||||||
|
t.Error("force-start must not run without a CSRF token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleGameBanMember(t *testing.T) {
|
||||||
|
gameID := uuid.New()
|
||||||
|
target := uuid.New()
|
||||||
|
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: gameID}}
|
||||||
|
router, csrf := newGamesConsoleRouter(t, games, nil, nil)
|
||||||
|
|
||||||
|
form := "_csrf=" + csrf.Token("ops") + "&user_id=" + target.String() + "&reason=cheating"
|
||||||
|
rec := consolePost(t, router, "/_gm/games/"+gameID.String()+"/ban-member", form)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if games.banCalls != 1 || games.lastBanUser != target || games.lastBanReason != "cheating" {
|
||||||
|
t.Errorf("ban recorded %d user=%s reason=%q", games.banCalls, games.lastBanUser, games.lastBanReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleGameBanMemberRejectsBadUUID(t *testing.T) {
|
||||||
|
gameID := uuid.New()
|
||||||
|
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: gameID}}
|
||||||
|
router, csrf := newGamesConsoleRouter(t, games, nil, nil)
|
||||||
|
|
||||||
|
rec := consolePost(t, router, "/_gm/games/"+gameID.String()+"/ban-member", "_csrf="+csrf.Token("ops")+"&user_id=not-a-uuid")
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
if games.banCalls != 0 {
|
||||||
|
t.Error("ban must not run with an invalid user id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleRuntimePatch(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
|
||||||
|
rt := &fakeRuntimeAdmin{}
|
||||||
|
router, csrf := newGamesConsoleRouter(t, games, rt, nil)
|
||||||
|
|
||||||
|
rec := consolePost(t, router, "/_gm/games/"+id.String()+"/runtime/patch", "_csrf="+csrf.Token("ops")+"&target_version=0.1.1")
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
if rt.patchCalls != 1 || rt.lastPatchVersion != "0.1.1" {
|
||||||
|
t.Errorf("patch recorded %d version=%q", rt.patchCalls, rt.lastPatchVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleRuntimePatchMissingVersion(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
|
||||||
|
rt := &fakeRuntimeAdmin{}
|
||||||
|
router, csrf := newGamesConsoleRouter(t, games, rt, nil)
|
||||||
|
|
||||||
|
rec := consolePost(t, router, "/_gm/games/"+id.String()+"/runtime/patch", "_csrf="+csrf.Token("ops"))
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
if rt.patchCalls != 0 {
|
||||||
|
t.Error("patch must not run without a target version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleEngineVersions(t *testing.T) {
|
||||||
|
ev := &fakeEngineVersionAdmin{list: []runtime.EngineVersion{{Version: "0.1.0", ImageRef: "img:0.1.0", Enabled: true}}}
|
||||||
|
router, csrf := newGamesConsoleRouter(t, nil, nil, ev)
|
||||||
|
|
||||||
|
rec := consoleGet(t, router, "/_gm/engine-versions")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
for _, want := range []string{"0.1.0", "img:0.1.0", "Register version", "Disable"} {
|
||||||
|
if !strings.Contains(rec.Body.String(), want) {
|
||||||
|
t.Errorf("engine versions page missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = consolePost(t, router, "/_gm/engine-versions", "_csrf="+csrf.Token("ops")+"&version=0.2.0&image_ref=img:0.2.0&enabled=true")
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("register status = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
if ev.registerCalls != 1 || ev.registered.Version != "0.2.0" || ev.registered.Enabled == nil || !*ev.registered.Enabled {
|
||||||
|
t.Errorf("register recorded %d version=%q enabled=%v", ev.registerCalls, ev.registered.Version, ev.registered.Enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = consolePost(t, router, "/_gm/engine-versions/0.1.0/disable", "_csrf="+csrf.Token("ops"))
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("disable status = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
if ev.disableCalls != 1 || ev.lastDisabled != "0.1.0" {
|
||||||
|
t.Errorf("disable recorded %d version=%q", ev.disableCalls, ev.lastDisabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleGamesUnavailable(t *testing.T) {
|
||||||
|
router, _ := newGamesConsoleRouter(t, nil, nil, nil)
|
||||||
|
|
||||||
|
rec := consoleGet(t, router, "/_gm/games")
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("status = %d, want 503", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/adminconsole"
|
||||||
|
"galaxy/backend/internal/diplomail"
|
||||||
|
"galaxy/backend/internal/mail"
|
||||||
|
"galaxy/backend/internal/notification"
|
||||||
|
"galaxy/backend/internal/server/clientip"
|
||||||
|
"galaxy/backend/internal/server/middleware/basicauth"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MailAdmin is the subset of the mail service the console uses.
|
||||||
|
type MailAdmin interface {
|
||||||
|
AdminListDeliveries(ctx context.Context, page, pageSize int) (mail.AdminListDeliveriesPage, error)
|
||||||
|
AdminGetDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error)
|
||||||
|
AdminListAttempts(ctx context.Context, deliveryID uuid.UUID) ([]mail.Attempt, error)
|
||||||
|
AdminResendDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error)
|
||||||
|
AdminListDeadLetters(ctx context.Context, page, pageSize int) (mail.AdminListDeadLettersPage, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationAdmin is the subset of the notification service the console uses.
|
||||||
|
type NotificationAdmin interface {
|
||||||
|
AdminListNotifications(ctx context.Context, page, pageSize int) (notification.AdminListNotificationsPage, error)
|
||||||
|
AdminListDeadLetters(ctx context.Context, page, pageSize int) (notification.AdminListDeadLettersPage, error)
|
||||||
|
AdminListMalformed(ctx context.Context, page, pageSize int) (notification.AdminListMalformedPage, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiplomailAdmin is the subset of the diplomail service the console uses.
|
||||||
|
type DiplomailAdmin interface {
|
||||||
|
SendAdminMultiGameBroadcast(ctx context.Context, in diplomail.SendMultiGameBroadcastInput) ([]diplomail.Message, int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const consoleSnapshotPageSize = 50
|
||||||
|
|
||||||
|
// MailPage renders GET /_gm/mail — paginated deliveries plus a dead-letter snapshot.
|
||||||
|
func (h *AdminConsoleHandlers) MailPage() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.mail == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page := parsePositiveQueryInt(c.Query("page"), 1)
|
||||||
|
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
deliveries, err := h.mail.AdminListDeliveries(ctx, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin console: list deliveries", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load deliveries.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dead, err := h.mail.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin console: list mail dead-letters", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load dead-letters.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(c, http.StatusOK, "mail", "mail", "Mail", toMailData(deliveries, dead))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailDeliveryDetail renders GET /_gm/mail/deliveries/:delivery_id.
|
||||||
|
func (h *AdminConsoleHandlers) MailDeliveryDetail() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.mail == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deliveryID, ok := parseConsoleDeliveryID(c, h)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
delivery, err := h.mail.AdminGetDelivery(ctx, deliveryID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, mail.ErrDeliveryNotFound) {
|
||||||
|
h.renderMessage(c, http.StatusNotFound, "mail", "Delivery not found", "No such delivery.", "bad", "/_gm/mail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("admin console: get delivery", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load the delivery.", "bad", "/_gm/mail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attempts, err := h.mail.AdminListAttempts(ctx, deliveryID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin console: list attempts", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load attempts.", "bad", "/_gm/mail")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(c, http.StatusOK, "mail_delivery", "mail", "Delivery", toMailDeliveryDetail(delivery, attempts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailResend handles POST /_gm/mail/deliveries/:delivery_id/resend.
|
||||||
|
func (h *AdminConsoleHandlers) MailResend() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.mail == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deliveryID, ok := parseConsoleDeliveryID(c, h)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := "/_gm/mail/deliveries/" + deliveryID.String()
|
||||||
|
if _, err := h.mail.AdminResendDelivery(c.Request.Context(), deliveryID); err != nil {
|
||||||
|
h.logger.Error("admin console: resend delivery", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "mail", "Resend failed", "Failed to resend the delivery (it may already be sent).", "bad", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationsPage renders GET /_gm/notifications — notifications, dead-letters,
|
||||||
|
// and malformed intents on one overview page.
|
||||||
|
func (h *AdminConsoleHandlers) NotificationsPage() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.notifications == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Notifications", "Notification administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
notifications, err := h.notifications.AdminListNotifications(ctx, 1, consoleSnapshotPageSize)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin console: list notifications", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load notifications.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dead, err := h.notifications.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin console: list notification dead-letters", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load dead-letters.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
malformed, err := h.notifications.AdminListMalformed(ctx, 1, consoleSnapshotPageSize)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin console: list malformed intents", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load malformed intents.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(c, http.StatusOK, "notifications", "mail", "Notifications", toNotificationsData(notifications, dead, malformed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastForm renders GET /_gm/broadcast.
|
||||||
|
func (h *AdminConsoleHandlers) BroadcastForm() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.diplomail == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(c, http.StatusOK, "broadcast", "mail", "Broadcast", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastSend handles POST /_gm/broadcast — multi-game admin broadcast.
|
||||||
|
func (h *AdminConsoleHandlers) BroadcastSend() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.diplomail == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username, _ := basicauth.UsernameFromContext(c.Request.Context())
|
||||||
|
gameIDs, err := parseGameIDList(c.PostForm("game_ids"))
|
||||||
|
if err != nil {
|
||||||
|
h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "Game IDs must be valid UUIDs.", "bad", "/_gm/broadcast")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, total, err := h.diplomail.SendAdminMultiGameBroadcast(c.Request.Context(), diplomail.SendMultiGameBroadcastInput{
|
||||||
|
CallerUsername: username,
|
||||||
|
Scope: strings.TrimSpace(c.PostForm("scope")),
|
||||||
|
GameIDs: gameIDs,
|
||||||
|
RecipientScope: strings.TrimSpace(c.PostForm("recipients")),
|
||||||
|
Subject: strings.TrimSpace(c.PostForm("subject")),
|
||||||
|
Body: c.PostForm("body"),
|
||||||
|
SenderIP: clientip.ExtractSourceIP(c),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, diplomail.ErrInvalidInput) {
|
||||||
|
h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "The broadcast was rejected: check the scope, recipients, and body.", "bad", "/_gm/broadcast")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("admin console: broadcast", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "mail", "Broadcast failed", "Failed to send the broadcast.", "bad", "/_gm/broadcast")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderMessage(c, http.StatusOK, "mail", "Broadcast sent", fmt.Sprintf("Broadcast delivered to %d recipients.", total), "ok", "/_gm/broadcast")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseConsoleDeliveryID parses the delivery_id path parameter, rendering a
|
||||||
|
// console message page on failure.
|
||||||
|
func parseConsoleDeliveryID(c *gin.Context, h *AdminConsoleHandlers) (uuid.UUID, bool) {
|
||||||
|
parsed, err := uuid.Parse(c.Param("delivery_id"))
|
||||||
|
if err != nil {
|
||||||
|
h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "delivery_id must be a valid UUID.", "bad", "/_gm/mail")
|
||||||
|
return uuid.Nil, false
|
||||||
|
}
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGameIDList parses a comma-separated list of UUIDs, ignoring blanks.
|
||||||
|
func parseGameIDList(raw string) ([]uuid.UUID, error) {
|
||||||
|
fields := strings.Split(raw, ",")
|
||||||
|
ids := make([]uuid.UUID, 0, len(fields))
|
||||||
|
for _, field := range fields {
|
||||||
|
field = strings.TrimSpace(field)
|
||||||
|
if field == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parsed, err := uuid.Parse(field)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, parsed)
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toMailData(deliveries mail.AdminListDeliveriesPage, dead mail.AdminListDeadLettersPage) adminconsole.MailData {
|
||||||
|
data := adminconsole.MailData{
|
||||||
|
Deliveries: make([]adminconsole.MailDeliveryRow, 0, len(deliveries.Items)),
|
||||||
|
DeadLetters: make([]adminconsole.MailDeadLetterRow, 0, len(dead.Items)),
|
||||||
|
Page: deliveries.Page,
|
||||||
|
PageSize: deliveries.PageSize,
|
||||||
|
Total: deliveries.Total,
|
||||||
|
PrevPage: deliveries.Page - 1,
|
||||||
|
NextPage: deliveries.Page + 1,
|
||||||
|
HasPrev: deliveries.Page > 1,
|
||||||
|
HasNext: int64(deliveries.Page*deliveries.PageSize) < deliveries.Total,
|
||||||
|
}
|
||||||
|
for _, d := range deliveries.Items {
|
||||||
|
data.Deliveries = append(data.Deliveries, adminconsole.MailDeliveryRow{
|
||||||
|
DeliveryID: d.DeliveryID.String(),
|
||||||
|
Template: d.TemplateID,
|
||||||
|
Status: d.Status,
|
||||||
|
Attempts: d.Attempts,
|
||||||
|
NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt),
|
||||||
|
Created: fmtConsoleTime(d.CreatedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, d := range dead.Items {
|
||||||
|
data.DeadLetters = append(data.DeadLetters, adminconsole.MailDeadLetterRow{
|
||||||
|
DeliveryID: d.DeliveryID.String(),
|
||||||
|
Reason: d.Reason,
|
||||||
|
Archived: fmtConsoleTime(d.ArchivedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func toMailDeliveryDetail(d mail.Delivery, attempts []mail.Attempt) adminconsole.MailDeliveryDetail {
|
||||||
|
detail := adminconsole.MailDeliveryDetail{
|
||||||
|
DeliveryID: d.DeliveryID.String(),
|
||||||
|
Template: d.TemplateID,
|
||||||
|
Status: d.Status,
|
||||||
|
Attempts: d.Attempts,
|
||||||
|
NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt),
|
||||||
|
LastError: d.LastError,
|
||||||
|
Created: fmtConsoleTime(d.CreatedAt),
|
||||||
|
Sent: fmtConsoleTimePtr(d.SentAt),
|
||||||
|
DeadLettered: fmtConsoleTimePtr(d.DeadLetteredAt),
|
||||||
|
CanResend: d.Status != mail.StatusSent,
|
||||||
|
AttemptRows: make([]adminconsole.MailAttemptRow, 0, len(attempts)),
|
||||||
|
}
|
||||||
|
for _, a := range attempts {
|
||||||
|
detail.AttemptRows = append(detail.AttemptRows, adminconsole.MailAttemptRow{
|
||||||
|
AttemptNo: a.AttemptNo,
|
||||||
|
Outcome: a.Outcome,
|
||||||
|
Started: fmtConsoleTime(a.StartedAt),
|
||||||
|
Finished: fmtConsoleTimePtr(a.FinishedAt),
|
||||||
|
Error: a.Error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
|
func toNotificationsData(notifications notification.AdminListNotificationsPage, dead notification.AdminListDeadLettersPage, malformed notification.AdminListMalformedPage) adminconsole.NotificationsData {
|
||||||
|
data := adminconsole.NotificationsData{
|
||||||
|
Notifications: make([]adminconsole.NotificationRow, 0, len(notifications.Items)),
|
||||||
|
DeadLetters: make([]adminconsole.NotificationDeadLetterRow, 0, len(dead.Items)),
|
||||||
|
Malformed: make([]adminconsole.MalformedRow, 0, len(malformed.Items)),
|
||||||
|
}
|
||||||
|
for _, n := range notifications.Items {
|
||||||
|
data.Notifications = append(data.Notifications, adminconsole.NotificationRow{
|
||||||
|
NotificationID: n.NotificationID.String(),
|
||||||
|
Kind: n.Kind,
|
||||||
|
UserID: optionalUUID(n.UserID),
|
||||||
|
Created: fmtConsoleTime(n.CreatedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, d := range dead.Items {
|
||||||
|
data.DeadLetters = append(data.DeadLetters, adminconsole.NotificationDeadLetterRow{
|
||||||
|
NotificationID: d.NotificationID.String(),
|
||||||
|
RouteID: d.RouteID.String(),
|
||||||
|
Reason: d.Reason,
|
||||||
|
Archived: fmtConsoleTime(d.ArchivedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, m := range malformed.Items {
|
||||||
|
data.Malformed = append(data.Malformed, adminconsole.MalformedRow{
|
||||||
|
ID: m.ID.String(),
|
||||||
|
Reason: m.Reason,
|
||||||
|
Received: fmtConsoleTime(m.ReceivedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// optionalUUID renders a nullable user id; system-scoped rows have none.
|
||||||
|
func optionalUUID(id *uuid.UUID) string {
|
||||||
|
if id == nil {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return id.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/adminconsole"
|
||||||
|
"galaxy/backend/internal/diplomail"
|
||||||
|
"galaxy/backend/internal/mail"
|
||||||
|
"galaxy/backend/internal/notification"
|
||||||
|
"galaxy/backend/internal/server/middleware/basicauth"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeMailAdmin struct {
|
||||||
|
deliveries mail.AdminListDeliveriesPage
|
||||||
|
dead mail.AdminListDeadLettersPage
|
||||||
|
delivery mail.Delivery
|
||||||
|
getErr error
|
||||||
|
attempts []mail.Attempt
|
||||||
|
resendCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeMailAdmin) AdminListDeliveries(context.Context, int, int) (mail.AdminListDeliveriesPage, error) {
|
||||||
|
return f.deliveries, nil
|
||||||
|
}
|
||||||
|
func (f *fakeMailAdmin) AdminGetDelivery(context.Context, uuid.UUID) (mail.Delivery, error) {
|
||||||
|
return f.delivery, f.getErr
|
||||||
|
}
|
||||||
|
func (f *fakeMailAdmin) AdminListAttempts(context.Context, uuid.UUID) ([]mail.Attempt, error) {
|
||||||
|
return f.attempts, nil
|
||||||
|
}
|
||||||
|
func (f *fakeMailAdmin) AdminResendDelivery(context.Context, uuid.UUID) (mail.Delivery, error) {
|
||||||
|
f.resendCalls++
|
||||||
|
return f.delivery, nil
|
||||||
|
}
|
||||||
|
func (f *fakeMailAdmin) AdminListDeadLetters(context.Context, int, int) (mail.AdminListDeadLettersPage, error) {
|
||||||
|
return f.dead, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeNotificationAdmin struct {
|
||||||
|
notifications notification.AdminListNotificationsPage
|
||||||
|
dead notification.AdminListDeadLettersPage
|
||||||
|
malformed notification.AdminListMalformedPage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeNotificationAdmin) AdminListNotifications(context.Context, int, int) (notification.AdminListNotificationsPage, error) {
|
||||||
|
return f.notifications, nil
|
||||||
|
}
|
||||||
|
func (f *fakeNotificationAdmin) AdminListDeadLetters(context.Context, int, int) (notification.AdminListDeadLettersPage, error) {
|
||||||
|
return f.dead, nil
|
||||||
|
}
|
||||||
|
func (f *fakeNotificationAdmin) AdminListMalformed(context.Context, int, int) (notification.AdminListMalformedPage, error) {
|
||||||
|
return f.malformed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeDiplomailAdmin struct {
|
||||||
|
total int
|
||||||
|
err error
|
||||||
|
broadcastCalls int
|
||||||
|
last diplomail.SendMultiGameBroadcastInput
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDiplomailAdmin) SendAdminMultiGameBroadcast(_ context.Context, in diplomail.SendMultiGameBroadcastInput) ([]diplomail.Message, int, error) {
|
||||||
|
f.broadcastCalls++
|
||||||
|
f.last = in
|
||||||
|
if f.err != nil {
|
||||||
|
return nil, 0, f.err
|
||||||
|
}
|
||||||
|
return nil, f.total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailConsoleRouter(t *testing.T, m MailAdmin, n NotificationAdmin, d DiplomailAdmin) (http.Handler, *adminconsole.CSRF) {
|
||||||
|
t.Helper()
|
||||||
|
csrf := adminconsole.NewCSRF([]byte("test-key"))
|
||||||
|
handler, err := NewRouter(RouterDependencies{
|
||||||
|
Logger: zap.NewNop(),
|
||||||
|
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
||||||
|
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf, Mail: m, Notifications: n, Diplomail: d}),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRouter: %v", err)
|
||||||
|
}
|
||||||
|
return handler, csrf
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleMailPage(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
m := &fakeMailAdmin{
|
||||||
|
deliveries: mail.AdminListDeliveriesPage{
|
||||||
|
Items: []mail.Delivery{{DeliveryID: id, TemplateID: "auth.login_code", Status: "pending", CreatedAt: time.Now()}},
|
||||||
|
Page: 1, PageSize: 50, Total: 1,
|
||||||
|
},
|
||||||
|
dead: mail.AdminListDeadLettersPage{
|
||||||
|
Items: []mail.DeadLetter{{DeliveryID: uuid.New(), Reason: "smtp 550", ArchivedAt: time.Now()}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
router, _ := mailConsoleRouter(t, m, nil, nil)
|
||||||
|
|
||||||
|
rec := consoleGet(t, router, "/_gm/mail")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
for _, want := range []string{"auth.login_code", "pending", "Dead-letters", "smtp 550"} {
|
||||||
|
if !strings.Contains(rec.Body.String(), want) {
|
||||||
|
t.Errorf("mail page missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleMailDeliveryDetail(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
m := &fakeMailAdmin{
|
||||||
|
delivery: mail.Delivery{DeliveryID: id, TemplateID: "auth.login_code", Status: "pending", Attempts: 2},
|
||||||
|
attempts: []mail.Attempt{{AttemptNo: 1, Outcome: "transient_failure", StartedAt: time.Now(), Error: "timeout"}},
|
||||||
|
}
|
||||||
|
router, _ := mailConsoleRouter(t, m, nil, nil)
|
||||||
|
|
||||||
|
rec := consoleGet(t, router, "/_gm/mail/deliveries/"+id.String())
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
for _, want := range []string{id.String(), "auth.login_code", "Attempts", "transient_failure", "Resend"} {
|
||||||
|
if !strings.Contains(rec.Body.String(), want) {
|
||||||
|
t.Errorf("delivery detail missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleMailDeliveryDetailNotFound(t *testing.T) {
|
||||||
|
m := &fakeMailAdmin{getErr: mail.ErrDeliveryNotFound}
|
||||||
|
router, _ := mailConsoleRouter(t, m, nil, nil)
|
||||||
|
|
||||||
|
rec := consoleGet(t, router, "/_gm/mail/deliveries/"+uuid.New().String())
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleMailResend(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
m := &fakeMailAdmin{delivery: mail.Delivery{DeliveryID: id}}
|
||||||
|
router, csrf := mailConsoleRouter(t, m, nil, nil)
|
||||||
|
|
||||||
|
rec := consolePost(t, router, "/_gm/mail/deliveries/"+id.String()+"/resend", "_csrf="+csrf.Token("ops"))
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
if m.resendCalls != 1 {
|
||||||
|
t.Errorf("AdminResendDelivery called %d times, want 1", m.resendCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleMailResendRejectsBadCSRF(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
m := &fakeMailAdmin{delivery: mail.Delivery{DeliveryID: id}}
|
||||||
|
router, _ := mailConsoleRouter(t, m, nil, nil)
|
||||||
|
|
||||||
|
rec := consolePost(t, router, "/_gm/mail/deliveries/"+id.String()+"/resend", "")
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403", rec.Code)
|
||||||
|
}
|
||||||
|
if m.resendCalls != 0 {
|
||||||
|
t.Error("resend must not run without a CSRF token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleNotificationsPage(t *testing.T) {
|
||||||
|
n := &fakeNotificationAdmin{
|
||||||
|
notifications: notification.AdminListNotificationsPage{Items: []notification.Notification{{NotificationID: uuid.New(), Kind: "lobby.invite.received"}}},
|
||||||
|
dead: notification.AdminListDeadLettersPage{Items: []notification.DeadLetter{{NotificationID: uuid.New(), RouteID: uuid.New(), Reason: "push gone"}}},
|
||||||
|
malformed: notification.AdminListMalformedPage{Items: []notification.MalformedIntent{{ID: uuid.New(), Reason: "bad shape"}}},
|
||||||
|
}
|
||||||
|
router, _ := mailConsoleRouter(t, nil, n, nil)
|
||||||
|
|
||||||
|
rec := consoleGet(t, router, "/_gm/notifications")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
for _, want := range []string{"lobby.invite.received", "push gone", "bad shape", "Malformed intents"} {
|
||||||
|
if !strings.Contains(rec.Body.String(), want) {
|
||||||
|
t.Errorf("notifications page missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleBroadcastForm(t *testing.T) {
|
||||||
|
router, _ := mailConsoleRouter(t, nil, nil, &fakeDiplomailAdmin{})
|
||||||
|
|
||||||
|
rec := consoleGet(t, router, "/_gm/broadcast")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "Send broadcast") {
|
||||||
|
t.Error("broadcast form missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleBroadcastSend(t *testing.T) {
|
||||||
|
d := &fakeDiplomailAdmin{total: 5}
|
||||||
|
router, csrf := mailConsoleRouter(t, nil, nil, d)
|
||||||
|
|
||||||
|
form := "_csrf=" + csrf.Token("ops") + "&scope=all_running&recipients=active&body=hello"
|
||||||
|
rec := consolePost(t, router, "/_gm/broadcast", form)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "5 recipients") {
|
||||||
|
t.Errorf("broadcast result missing recipient count; body=%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if d.broadcastCalls != 1 || d.last.Scope != "all_running" || d.last.Body != "hello" || d.last.CallerUsername != "ops" {
|
||||||
|
t.Errorf("broadcast input = %+v (calls=%d)", d.last, d.broadcastCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleBroadcastSendBadGameIDs(t *testing.T) {
|
||||||
|
d := &fakeDiplomailAdmin{}
|
||||||
|
router, csrf := mailConsoleRouter(t, nil, nil, d)
|
||||||
|
|
||||||
|
form := "_csrf=" + csrf.Token("ops") + "&scope=selected&game_ids=not-a-uuid&body=hello"
|
||||||
|
rec := consolePost(t, router, "/_gm/broadcast", form)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
if d.broadcastCalls != 0 {
|
||||||
|
t.Error("broadcast must not run with invalid game ids")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleMailUnavailable(t *testing.T) {
|
||||||
|
router, _ := mailConsoleRouter(t, nil, nil, nil)
|
||||||
|
|
||||||
|
rec := consoleGet(t, router, "/_gm/mail")
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("status = %d, want 503", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/admin"
|
||||||
|
"galaxy/backend/internal/adminconsole"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OperatorAdmin is the subset of the admin-account service the console uses.
|
||||||
|
// *admin.Service satisfies it.
|
||||||
|
type OperatorAdmin interface {
|
||||||
|
List(ctx context.Context) ([]admin.Admin, error)
|
||||||
|
Create(ctx context.Context, in admin.CreateInput) (admin.Admin, error)
|
||||||
|
Disable(ctx context.Context, username string) (admin.Admin, error)
|
||||||
|
Enable(ctx context.Context, username string) (admin.Admin, error)
|
||||||
|
ResetPassword(ctx context.Context, username, password string) (admin.Admin, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OperatorsList renders GET /_gm/operators.
|
||||||
|
func (h *AdminConsoleHandlers) OperatorsList() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.operators == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
admins, err := h.operators.List(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin console: list operators", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "operators", "Operators", "Failed to load operators.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(c, http.StatusOK, "operators", "operators", "Operators", toOperatorsData(admins))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OperatorCreate handles POST /_gm/operators.
|
||||||
|
func (h *AdminConsoleHandlers) OperatorCreate() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.operators == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := h.operators.Create(c.Request.Context(), admin.CreateInput{
|
||||||
|
Username: strings.TrimSpace(c.PostForm("username")),
|
||||||
|
Password: c.PostForm("password"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, admin.ErrUsernameTaken):
|
||||||
|
h.renderMessage(c, http.StatusConflict, "operators", "Username taken", "That username is already in use.", "bad", "/_gm/operators")
|
||||||
|
case errors.Is(err, admin.ErrInvalidInput):
|
||||||
|
h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "Username and password are required.", "bad", "/_gm/operators")
|
||||||
|
default:
|
||||||
|
h.logger.Error("admin console: create operator", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "operators", "Create failed", "Failed to create the operator.", "bad", "/_gm/operators")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, "/_gm/operators")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OperatorDisable handles POST /_gm/operators/:username/disable.
|
||||||
|
func (h *AdminConsoleHandlers) OperatorDisable() gin.HandlerFunc {
|
||||||
|
return h.operatorAction("disable", func(ctx context.Context, username string) error {
|
||||||
|
_, err := h.operators.Disable(ctx, username)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// OperatorEnable handles POST /_gm/operators/:username/enable.
|
||||||
|
func (h *AdminConsoleHandlers) OperatorEnable() gin.HandlerFunc {
|
||||||
|
return h.operatorAction("enable", func(ctx context.Context, username string) error {
|
||||||
|
_, err := h.operators.Enable(ctx, username)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// OperatorResetPassword handles POST /_gm/operators/:username/reset-password.
|
||||||
|
func (h *AdminConsoleHandlers) OperatorResetPassword() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.operators == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := c.Param("username")
|
||||||
|
password := c.PostForm("password")
|
||||||
|
if strings.TrimSpace(password) == "" {
|
||||||
|
h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "A new password is required.", "bad", "/_gm/operators")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := h.operators.ResetPassword(c.Request.Context(), username, password); err != nil {
|
||||||
|
if errors.Is(err, admin.ErrNotFound) {
|
||||||
|
h.renderMessage(c, http.StatusNotFound, "operators", "Operator not found", "No such operator.", "bad", "/_gm/operators")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, admin.ErrInvalidInput) {
|
||||||
|
h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "The password was rejected.", "bad", "/_gm/operators")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("admin console: reset operator password", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "operators", "Reset failed", "Failed to reset the password.", "bad", "/_gm/operators")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, "/_gm/operators")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// operatorAction is the shared shape for operator POST actions that take only
|
||||||
|
// the username and redirect back to the list.
|
||||||
|
func (h *AdminConsoleHandlers) operatorAction(label string, run func(context.Context, string) error) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.operators == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := run(c.Request.Context(), c.Param("username")); err != nil {
|
||||||
|
if errors.Is(err, admin.ErrNotFound) {
|
||||||
|
h.renderMessage(c, http.StatusNotFound, "operators", "Operator not found", "No such operator.", "bad", "/_gm/operators")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("admin console: operator "+label, zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "operators", "Action failed", "The "+label+" action failed.", "bad", "/_gm/operators")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, "/_gm/operators")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toOperatorsData maps admin accounts into the operators view model.
|
||||||
|
func toOperatorsData(admins []admin.Admin) adminconsole.OperatorsData {
|
||||||
|
data := adminconsole.OperatorsData{Items: make([]adminconsole.OperatorRow, 0, len(admins))}
|
||||||
|
for _, a := range admins {
|
||||||
|
data.Items = append(data.Items, adminconsole.OperatorRow{
|
||||||
|
Username: a.Username,
|
||||||
|
CreatedAt: fmtConsoleTime(a.CreatedAt),
|
||||||
|
LastUsedAt: fmtConsoleTimePtr(a.LastUsedAt),
|
||||||
|
Disabled: a.DisabledAt != nil,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/admin"
|
||||||
|
"galaxy/backend/internal/adminconsole"
|
||||||
|
"galaxy/backend/internal/server/middleware/basicauth"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeOperatorAdmin struct {
|
||||||
|
list []admin.Admin
|
||||||
|
createErr error
|
||||||
|
|
||||||
|
created admin.CreateInput
|
||||||
|
createCalls int
|
||||||
|
disableCalls int
|
||||||
|
enableCalls int
|
||||||
|
resetCalls int
|
||||||
|
lastResetUser string
|
||||||
|
lastResetPass string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeOperatorAdmin) List(context.Context) ([]admin.Admin, error) { return f.list, nil }
|
||||||
|
func (f *fakeOperatorAdmin) Create(_ context.Context, in admin.CreateInput) (admin.Admin, error) {
|
||||||
|
f.createCalls++
|
||||||
|
f.created = in
|
||||||
|
if f.createErr != nil {
|
||||||
|
return admin.Admin{}, f.createErr
|
||||||
|
}
|
||||||
|
return admin.Admin{Username: in.Username}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeOperatorAdmin) Disable(_ context.Context, username string) (admin.Admin, error) {
|
||||||
|
f.disableCalls++
|
||||||
|
return admin.Admin{Username: username}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeOperatorAdmin) Enable(_ context.Context, username string) (admin.Admin, error) {
|
||||||
|
f.enableCalls++
|
||||||
|
return admin.Admin{Username: username}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeOperatorAdmin) ResetPassword(_ context.Context, username, password string) (admin.Admin, error) {
|
||||||
|
f.resetCalls++
|
||||||
|
f.lastResetUser = username
|
||||||
|
f.lastResetPass = password
|
||||||
|
return admin.Admin{Username: username}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func operatorsRouter(t *testing.T, operators OperatorAdmin) (http.Handler, *adminconsole.CSRF) {
|
||||||
|
t.Helper()
|
||||||
|
csrf := adminconsole.NewCSRF([]byte("test-key"))
|
||||||
|
handler, err := NewRouter(RouterDependencies{
|
||||||
|
Logger: zap.NewNop(),
|
||||||
|
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
||||||
|
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf, Operators: operators}),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRouter: %v", err)
|
||||||
|
}
|
||||||
|
return handler, csrf
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleOperatorsList(t *testing.T) {
|
||||||
|
fake := &fakeOperatorAdmin{list: []admin.Admin{{Username: "root"}}}
|
||||||
|
router, _ := operatorsRouter(t, fake)
|
||||||
|
|
||||||
|
rec := consoleGet(t, router, "/_gm/operators")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
for _, want := range []string{"root", "Create operator", "Reset"} {
|
||||||
|
if !strings.Contains(rec.Body.String(), want) {
|
||||||
|
t.Errorf("operators page missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleOperatorCreate(t *testing.T) {
|
||||||
|
fake := &fakeOperatorAdmin{}
|
||||||
|
router, csrf := operatorsRouter(t, fake)
|
||||||
|
|
||||||
|
rec := consolePost(t, router, "/_gm/operators", "_csrf="+csrf.Token("ops")+"&username=mod&password=s3cret")
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if fake.createCalls != 1 || fake.created.Username != "mod" || fake.created.Password != "s3cret" {
|
||||||
|
t.Errorf("create recorded %d username=%q", fake.createCalls, fake.created.Username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleOperatorCreateConflict(t *testing.T) {
|
||||||
|
fake := &fakeOperatorAdmin{createErr: admin.ErrUsernameTaken}
|
||||||
|
router, csrf := operatorsRouter(t, fake)
|
||||||
|
|
||||||
|
rec := consolePost(t, router, "/_gm/operators", "_csrf="+csrf.Token("ops")+"&username=root&password=x")
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("status = %d, want 409", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleOperatorDisableEnable(t *testing.T) {
|
||||||
|
fake := &fakeOperatorAdmin{}
|
||||||
|
router, csrf := operatorsRouter(t, fake)
|
||||||
|
|
||||||
|
if rec := consolePost(t, router, "/_gm/operators/root/disable", "_csrf="+csrf.Token("ops")); rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("disable status = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := consolePost(t, router, "/_gm/operators/root/enable", "_csrf="+csrf.Token("ops")); rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("enable status = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
if fake.disableCalls != 1 || fake.enableCalls != 1 {
|
||||||
|
t.Errorf("disable=%d enable=%d, want 1/1", fake.disableCalls, fake.enableCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleOperatorResetPassword(t *testing.T) {
|
||||||
|
fake := &fakeOperatorAdmin{}
|
||||||
|
router, csrf := operatorsRouter(t, fake)
|
||||||
|
|
||||||
|
rec := consolePost(t, router, "/_gm/operators/root/reset-password", "_csrf="+csrf.Token("ops")+"&password=newpass")
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
if fake.resetCalls != 1 || fake.lastResetUser != "root" || fake.lastResetPass != "newpass" {
|
||||||
|
t.Errorf("reset recorded %d user=%q", fake.resetCalls, fake.lastResetUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleOperatorResetPasswordMissing(t *testing.T) {
|
||||||
|
fake := &fakeOperatorAdmin{}
|
||||||
|
router, csrf := operatorsRouter(t, fake)
|
||||||
|
|
||||||
|
rec := consolePost(t, router, "/_gm/operators/root/reset-password", "_csrf="+csrf.Token("ops"))
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
if fake.resetCalls != 0 {
|
||||||
|
t.Error("reset must not run without a password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleOperatorRejectsBadCSRF(t *testing.T) {
|
||||||
|
fake := &fakeOperatorAdmin{}
|
||||||
|
router, _ := operatorsRouter(t, fake)
|
||||||
|
|
||||||
|
rec := consolePost(t, router, "/_gm/operators/root/disable", "")
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403", rec.Code)
|
||||||
|
}
|
||||||
|
if fake.disableCalls != 0 {
|
||||||
|
t.Error("disable must not run without a CSRF token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleOperatorsUnavailable(t *testing.T) {
|
||||||
|
router, _ := operatorsRouter(t, nil)
|
||||||
|
|
||||||
|
rec := consoleGet(t, router, "/_gm/operators")
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("status = %d, want 503", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/adminconsole"
|
||||||
|
"galaxy/backend/internal/opsstatus"
|
||||||
|
"galaxy/backend/internal/server/middleware/basicauth"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeMonitor is a static opsstatus.Reader for dashboard rendering tests.
|
||||||
|
type fakeMonitor struct {
|
||||||
|
snapshot opsstatus.Snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeMonitor) Collect(context.Context) opsstatus.Snapshot {
|
||||||
|
return f.snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConsoleTestRouter(t *testing.T) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
handler, err := NewRouter(RouterDependencies{
|
||||||
|
Logger: zap.NewNop(),
|
||||||
|
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
||||||
|
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: adminconsole.NewCSRF([]byte("test-key"))}),
|
||||||
|
})
|
||||||
|
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 TestAdminConsoleDashboardShowsMonitoring(t *testing.T) {
|
||||||
|
monitor := fakeMonitor{snapshot: opsstatus.Snapshot{
|
||||||
|
PostgresHealthy: true,
|
||||||
|
Runtimes: []opsstatus.StatusCount{{Status: "running", Count: 3}, {Status: "stopped", Count: 1}},
|
||||||
|
MailDeliveries: []opsstatus.StatusCount{{Status: "pending", Count: 2}},
|
||||||
|
NotificationRoutes: []opsstatus.StatusCount{{Status: "published", Count: 9}},
|
||||||
|
NotificationMalformed: 4,
|
||||||
|
Errors: []string{"notification route counts: boom"},
|
||||||
|
}}
|
||||||
|
|
||||||
|
handler, err := NewRouter(RouterDependencies{
|
||||||
|
Logger: zap.NewNop(),
|
||||||
|
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
||||||
|
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{
|
||||||
|
CSRF: adminconsole.NewCSRF([]byte("test-key")),
|
||||||
|
Monitor: monitor,
|
||||||
|
Ready: func() bool { return true },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRouter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/_gm/", nil)
|
||||||
|
req.SetBasicAuth("ops", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"Game runtimes", "running", "stopped",
|
||||||
|
"Mail deliveries", "pending",
|
||||||
|
"Notification routes", "published",
|
||||||
|
"Malformed notifications",
|
||||||
|
"notification route counts: boom",
|
||||||
|
"healthy",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("dashboard body missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminConsoleDashboardWithoutMonitor(t *testing.T) {
|
||||||
|
router := newConsoleTestRouter(t) // no monitor wired
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/_gm/", nil)
|
||||||
|
req.SetBasicAuth("ops", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "Monitoring is not wired") {
|
||||||
|
t.Error("dashboard without a monitor should note that monitoring is unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminConsoleServesAsset(t *testing.T) {
|
||||||
|
router := newConsoleTestRouter(t)
|
||||||
|
|
||||||
|
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(AdminConsoleDeps{CSRF: csrf})
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/adminconsole"
|
||||||
|
"galaxy/backend/internal/server/middleware/basicauth"
|
||||||
|
"galaxy/backend/internal/user"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserAdmin is the subset of the user service the operator console depends on.
|
||||||
|
// *user.Service satisfies it; tests supply a fake so the console pages render
|
||||||
|
// without a database.
|
||||||
|
type UserAdmin interface {
|
||||||
|
ListAccounts(ctx context.Context, page, pageSize int) (user.AccountPage, error)
|
||||||
|
GetAccount(ctx context.Context, userID uuid.UUID) (user.Account, error)
|
||||||
|
ApplySanction(ctx context.Context, input user.ApplySanctionInput) (user.Account, error)
|
||||||
|
ApplyEntitlement(ctx context.Context, input user.ApplyEntitlementInput) (user.Account, error)
|
||||||
|
SoftDelete(ctx context.Context, userID uuid.UUID, actor user.ActorRef) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleTiers lists the selectable entitlement tiers in display order.
|
||||||
|
var consoleTiers = []string{user.TierFree, user.TierMonthly, user.TierYearly, user.TierPermanent}
|
||||||
|
|
||||||
|
// UsersList renders GET /_gm/users — the paginated account list.
|
||||||
|
func (h *AdminConsoleHandlers) UsersList() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.users == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page := parsePositiveQueryInt(c.Query("page"), 1)
|
||||||
|
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
||||||
|
|
||||||
|
result, err := h.users.ListAccounts(c.Request.Context(), page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin console: list users", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "users", "Users", "Failed to load users.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(c, http.StatusOK, "users", "users", "Users", toUsersListData(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserDetail renders GET /_gm/users/:user_id.
|
||||||
|
func (h *AdminConsoleHandlers) UserDetail() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.users == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, ok := parseUserIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
account, err := h.users.GetAccount(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, user.ErrAccountNotFound) {
|
||||||
|
h.renderMessage(c, http.StatusNotFound, "users", "User not found", "No such user, or the account has been soft-deleted.", "bad", "/_gm/users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("admin console: get user", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "users", "Users", "Failed to load the user.", "bad", "/_gm/users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.render(c, http.StatusOK, "user_detail", "users", account.Email, toUserDetailData(account))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserBlock handles POST /_gm/users/:user_id/block — applies a permanent block.
|
||||||
|
func (h *AdminConsoleHandlers) UserBlock() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.users == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, ok := parseUserIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := "/_gm/users/" + userID.String()
|
||||||
|
reason := strings.TrimSpace(c.PostForm("reason_code"))
|
||||||
|
if reason == "" {
|
||||||
|
h.renderMessage(c, http.StatusBadRequest, "users", "Invalid input", "A reason is required to block a user.", "bad", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := h.users.ApplySanction(c.Request.Context(), user.ApplySanctionInput{
|
||||||
|
UserID: userID,
|
||||||
|
SanctionCode: user.SanctionCodePermanentBlock,
|
||||||
|
Scope: "account",
|
||||||
|
ReasonCode: reason,
|
||||||
|
Actor: actorFromContext(c),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin console: block user", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "users", "Block failed", "Failed to block the user.", "bad", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserEntitlement handles POST /_gm/users/:user_id/entitlement.
|
||||||
|
func (h *AdminConsoleHandlers) UserEntitlement() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.users == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, ok := parseUserIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := "/_gm/users/" + userID.String()
|
||||||
|
tier := strings.TrimSpace(c.PostForm("tier"))
|
||||||
|
source := strings.TrimSpace(c.PostForm("source"))
|
||||||
|
if source == "" {
|
||||||
|
source = "admin"
|
||||||
|
}
|
||||||
|
_, err := h.users.ApplyEntitlement(c.Request.Context(), user.ApplyEntitlementInput{
|
||||||
|
UserID: userID,
|
||||||
|
Tier: tier,
|
||||||
|
Source: source,
|
||||||
|
Actor: actorFromContext(c),
|
||||||
|
ReasonCode: strings.TrimSpace(c.PostForm("reason_code")),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, user.ErrInvalidInput) {
|
||||||
|
h.renderMessage(c, http.StatusBadRequest, "users", "Invalid input", "The entitlement request was rejected: check the tier.", "bad", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("admin console: apply entitlement", zap.Error(err))
|
||||||
|
h.renderMessage(c, http.StatusInternalServerError, "users", "Entitlement failed", "Failed to update the entitlement.", "bad", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserSoftDelete handles POST /_gm/users/:user_id/soft-delete.
|
||||||
|
func (h *AdminConsoleHandlers) UserSoftDelete() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if h.users == nil {
|
||||||
|
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, ok := parseUserIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.users.SoftDelete(c.Request.Context(), userID, actorFromContext(c)); err != nil {
|
||||||
|
if errors.Is(err, user.ErrAccountNotFound) {
|
||||||
|
h.renderMessage(c, http.StatusNotFound, "users", "User not found", "No such user.", "bad", "/_gm/users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A cascade error does not undo the soft delete; log and proceed.
|
||||||
|
h.logger.Warn("admin console: soft-delete cascade returned error", zap.Error(err))
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusSeeOther, "/_gm/users")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// actorFromContext builds the admin ActorRef for audit trails from the
|
||||||
|
// authenticated operator username stored by the Basic Auth middleware.
|
||||||
|
func actorFromContext(c *gin.Context) user.ActorRef {
|
||||||
|
username, _ := basicauth.UsernameFromContext(c.Request.Context())
|
||||||
|
return user.ActorRef{Type: "admin", ID: username}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toUsersListData maps an account page into the users list view model.
|
||||||
|
func toUsersListData(page user.AccountPage) adminconsole.UsersListData {
|
||||||
|
data := adminconsole.UsersListData{
|
||||||
|
Items: make([]adminconsole.UserRow, 0, len(page.Items)),
|
||||||
|
Page: page.Page,
|
||||||
|
PageSize: page.PageSize,
|
||||||
|
Total: page.Total,
|
||||||
|
PrevPage: page.Page - 1,
|
||||||
|
NextPage: page.Page + 1,
|
||||||
|
HasPrev: page.Page > 1,
|
||||||
|
HasNext: page.Page*page.PageSize < page.Total,
|
||||||
|
}
|
||||||
|
for _, account := range page.Items {
|
||||||
|
data.Items = append(data.Items, adminconsole.UserRow{
|
||||||
|
UserID: account.UserID.String(),
|
||||||
|
Email: account.Email,
|
||||||
|
UserName: account.UserName,
|
||||||
|
DisplayName: account.DisplayName,
|
||||||
|
Tier: account.Entitlement.Tier,
|
||||||
|
Blocked: account.PermanentBlock,
|
||||||
|
Deleted: account.DeletedAt != nil,
|
||||||
|
CreatedAt: fmtConsoleTime(account.CreatedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// toUserDetailData maps an account aggregate into the detail view model.
|
||||||
|
func toUserDetailData(account user.Account) adminconsole.UserDetailData {
|
||||||
|
data := adminconsole.UserDetailData{
|
||||||
|
UserID: account.UserID.String(),
|
||||||
|
Email: account.Email,
|
||||||
|
UserName: account.UserName,
|
||||||
|
DisplayName: account.DisplayName,
|
||||||
|
PreferredLanguage: account.PreferredLanguage,
|
||||||
|
TimeZone: account.TimeZone,
|
||||||
|
DeclaredCountry: account.DeclaredCountry,
|
||||||
|
Blocked: account.PermanentBlock,
|
||||||
|
Deleted: account.DeletedAt != nil,
|
||||||
|
CreatedAt: fmtConsoleTime(account.CreatedAt),
|
||||||
|
UpdatedAt: fmtConsoleTime(account.UpdatedAt),
|
||||||
|
Tier: account.Entitlement.Tier,
|
||||||
|
IsPaid: account.Entitlement.IsPaid,
|
||||||
|
EntitlementSource: account.Entitlement.Source,
|
||||||
|
EntitlementReason: account.Entitlement.ReasonCode,
|
||||||
|
EntitlementEnds: fmtConsoleTimePtr(account.Entitlement.EndsAt),
|
||||||
|
Tiers: consoleTiers,
|
||||||
|
}
|
||||||
|
for _, sanction := range account.ActiveSanctions {
|
||||||
|
data.Sanctions = append(data.Sanctions, adminconsole.SanctionView{
|
||||||
|
SanctionCode: sanction.SanctionCode,
|
||||||
|
Scope: sanction.Scope,
|
||||||
|
ReasonCode: sanction.ReasonCode,
|
||||||
|
AppliedAt: fmtConsoleTime(sanction.AppliedAt),
|
||||||
|
ExpiresAt: fmtConsoleTimePtr(sanction.ExpiresAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmtConsoleTime renders a timestamp for display in the console.
|
||||||
|
func fmtConsoleTime(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.UTC().Format("2006-01-02 15:04 UTC")
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmtConsoleTimePtr renders an optional timestamp, returning "" when nil.
|
||||||
|
func fmtConsoleTimePtr(t *time.Time) string {
|
||||||
|
if t == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmtConsoleTime(*t)
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/adminconsole"
|
||||||
|
"galaxy/backend/internal/server/middleware/basicauth"
|
||||||
|
"galaxy/backend/internal/user"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeUserAdmin records calls so the console handlers can be exercised without
|
||||||
|
// a database.
|
||||||
|
type fakeUserAdmin struct {
|
||||||
|
page user.AccountPage
|
||||||
|
account user.Account
|
||||||
|
getErr error
|
||||||
|
|
||||||
|
sanctionCalls int
|
||||||
|
lastSanction user.ApplySanctionInput
|
||||||
|
entitlementCall int
|
||||||
|
lastEntitlement user.ApplyEntitlementInput
|
||||||
|
softDeleteCalls int
|
||||||
|
lastSoftActor user.ActorRef
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeUserAdmin) ListAccounts(context.Context, int, int) (user.AccountPage, error) {
|
||||||
|
return f.page, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeUserAdmin) GetAccount(context.Context, uuid.UUID) (user.Account, error) {
|
||||||
|
return f.account, f.getErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeUserAdmin) ApplySanction(_ context.Context, in user.ApplySanctionInput) (user.Account, error) {
|
||||||
|
f.sanctionCalls++
|
||||||
|
f.lastSanction = in
|
||||||
|
return f.account, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeUserAdmin) ApplyEntitlement(_ context.Context, in user.ApplyEntitlementInput) (user.Account, error) {
|
||||||
|
f.entitlementCall++
|
||||||
|
f.lastEntitlement = in
|
||||||
|
return f.account, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeUserAdmin) SoftDelete(_ context.Context, _ uuid.UUID, actor user.ActorRef) error {
|
||||||
|
f.softDeleteCalls++
|
||||||
|
f.lastSoftActor = actor
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUsersConsoleRouter(t *testing.T, users UserAdmin) (http.Handler, *adminconsole.CSRF) {
|
||||||
|
t.Helper()
|
||||||
|
csrf := adminconsole.NewCSRF([]byte("test-key"))
|
||||||
|
handler, err := NewRouter(RouterDependencies{
|
||||||
|
Logger: zap.NewNop(),
|
||||||
|
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
||||||
|
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf, Users: users}),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRouter: %v", err)
|
||||||
|
}
|
||||||
|
return handler, csrf
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleUsersList(t *testing.T) {
|
||||||
|
fake := &fakeUserAdmin{page: user.AccountPage{
|
||||||
|
Items: []user.Account{
|
||||||
|
{UserID: uuid.New(), Email: "alice@example.test", UserName: "alice"},
|
||||||
|
{UserID: uuid.New(), Email: "bob@example.test", UserName: "bob", PermanentBlock: true},
|
||||||
|
},
|
||||||
|
Page: 1, PageSize: 50, Total: 2,
|
||||||
|
}}
|
||||||
|
router, _ := newUsersConsoleRouter(t, fake)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/_gm/users", nil)
|
||||||
|
req.SetBasicAuth("ops", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
for _, want := range []string{"alice@example.test", "bob@example.test", "blocked", "page 1"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("users list missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleUserDetailRendersForms(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
fake := &fakeUserAdmin{account: user.Account{
|
||||||
|
UserID: id, Email: "alice@example.test", UserName: "alice",
|
||||||
|
Entitlement: user.EntitlementSnapshot{Tier: user.TierFree},
|
||||||
|
}}
|
||||||
|
router, csrf := newUsersConsoleRouter(t, fake)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/_gm/users/"+id.String(), nil)
|
||||||
|
req.SetBasicAuth("ops", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"alice@example.test",
|
||||||
|
"Permanently block",
|
||||||
|
"Update entitlement",
|
||||||
|
"Soft-delete account",
|
||||||
|
csrf.Token("ops"),
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("user detail missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleUserDetailNotFound(t *testing.T) {
|
||||||
|
fake := &fakeUserAdmin{getErr: user.ErrAccountNotFound}
|
||||||
|
router, _ := newUsersConsoleRouter(t, fake)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/_gm/users/"+uuid.New().String(), nil)
|
||||||
|
req.SetBasicAuth("ops", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "not found") {
|
||||||
|
t.Error("expected a not-found message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleUserBlock(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
|
||||||
|
router, csrf := newUsersConsoleRouter(t, fake)
|
||||||
|
|
||||||
|
form := "_csrf=" + csrf.Token("ops") + "&reason_code=spam"
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", strings.NewReader(form))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Origin", "https://galaxy.lan")
|
||||||
|
req.SetBasicAuth("ops", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if fake.sanctionCalls != 1 {
|
||||||
|
t.Fatalf("ApplySanction called %d times, want 1", fake.sanctionCalls)
|
||||||
|
}
|
||||||
|
if fake.lastSanction.SanctionCode != user.SanctionCodePermanentBlock {
|
||||||
|
t.Errorf("sanction code = %q, want permanent_block", fake.lastSanction.SanctionCode)
|
||||||
|
}
|
||||||
|
if fake.lastSanction.Scope != "account" {
|
||||||
|
t.Errorf("scope = %q, want account", fake.lastSanction.Scope)
|
||||||
|
}
|
||||||
|
if fake.lastSanction.ReasonCode != "spam" {
|
||||||
|
t.Errorf("reason = %q, want spam", fake.lastSanction.ReasonCode)
|
||||||
|
}
|
||||||
|
if fake.lastSanction.Actor.Type != "admin" || fake.lastSanction.Actor.ID != "ops" {
|
||||||
|
t.Errorf("actor = %+v, want admin/ops", fake.lastSanction.Actor)
|
||||||
|
}
|
||||||
|
if fake.lastSanction.UserID != id {
|
||||||
|
t.Errorf("sanction user id = %s, want %s", fake.lastSanction.UserID, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleUserBlockMissingReason(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
|
||||||
|
router, csrf := newUsersConsoleRouter(t, fake)
|
||||||
|
|
||||||
|
form := "_csrf=" + csrf.Token("ops")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", strings.NewReader(form))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Origin", "https://galaxy.lan")
|
||||||
|
req.SetBasicAuth("ops", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
if fake.sanctionCalls != 0 {
|
||||||
|
t.Errorf("ApplySanction must not be called without a reason")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleUserBlockRejectsBadCSRF(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
|
||||||
|
router, _ := newUsersConsoleRouter(t, fake)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", strings.NewReader("reason_code=spam"))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Origin", "https://galaxy.lan")
|
||||||
|
req.SetBasicAuth("ops", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403", rec.Code)
|
||||||
|
}
|
||||||
|
if fake.sanctionCalls != 0 {
|
||||||
|
t.Errorf("ApplySanction must not run when the CSRF token is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleUserEntitlement(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
|
||||||
|
router, csrf := newUsersConsoleRouter(t, fake)
|
||||||
|
|
||||||
|
form := "_csrf=" + csrf.Token("ops") + "&tier=monthly&source=admin&reason_code=promo"
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/entitlement", strings.NewReader(form))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Origin", "https://galaxy.lan")
|
||||||
|
req.SetBasicAuth("ops", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if fake.entitlementCall != 1 {
|
||||||
|
t.Fatalf("ApplyEntitlement called %d times, want 1", fake.entitlementCall)
|
||||||
|
}
|
||||||
|
if fake.lastEntitlement.Tier != user.TierMonthly {
|
||||||
|
t.Errorf("tier = %q, want monthly", fake.lastEntitlement.Tier)
|
||||||
|
}
|
||||||
|
if fake.lastEntitlement.Actor.ID != "ops" {
|
||||||
|
t.Errorf("actor id = %q, want ops", fake.lastEntitlement.Actor.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleUserSoftDelete(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
|
||||||
|
router, csrf := newUsersConsoleRouter(t, fake)
|
||||||
|
|
||||||
|
form := "_csrf=" + csrf.Token("ops")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/soft-delete", strings.NewReader(form))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Origin", "https://galaxy.lan")
|
||||||
|
req.SetBasicAuth("ops", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Location"); got != "/_gm/users" {
|
||||||
|
t.Errorf("redirect Location = %q, want /_gm/users", got)
|
||||||
|
}
|
||||||
|
if fake.softDeleteCalls != 1 {
|
||||||
|
t.Fatalf("SoftDelete called %d times, want 1", fake.softDeleteCalls)
|
||||||
|
}
|
||||||
|
if fake.lastSoftActor.ID != "ops" {
|
||||||
|
t.Errorf("soft-delete actor = %q, want ops", fake.lastSoftActor.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleUsersUnavailable(t *testing.T) {
|
||||||
|
router, _ := newUsersConsoleRouter(t, nil) // no user service wired
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/_gm/users", nil)
|
||||||
|
req.SetBasicAuth("ops", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("status = %d, want 503", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,13 @@ type RouterDependencies struct {
|
|||||||
AdminGeo *AdminGeoHandlers
|
AdminGeo *AdminGeoHandlers
|
||||||
InternalSessions *InternalSessionsHandlers
|
InternalSessions *InternalSessionsHandlers
|
||||||
InternalUsers *InternalUsersHandlers
|
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
|
// 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)
|
registerUserRoutes(router, instruments, deps)
|
||||||
registerAdminRoutes(router, instruments, deps)
|
registerAdminRoutes(router, instruments, deps)
|
||||||
registerInternalRoutes(router, instruments, deps)
|
registerInternalRoutes(router, instruments, deps)
|
||||||
|
registerAdminConsoleRoutes(router, deps)
|
||||||
|
|
||||||
router.NoMethod(func(c *gin.Context) {
|
router.NoMethod(func(c *gin.Context) {
|
||||||
if allow := allowedMethodsForPath(c.Request.URL.Path); allow != "" {
|
if allow := allowedMethodsForPath(c.Request.URL.Path); allow != "" {
|
||||||
@@ -364,6 +372,57 @@ func registerInternalRoutes(router *gin.Engine, instruments *metrics.Instruments
|
|||||||
users.GET("/:user_id/account-internal", deps.InternalUsers.GetAccountInternal())
|
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())
|
||||||
|
|
||||||
|
group.GET("/users", deps.AdminConsole.UsersList())
|
||||||
|
group.GET("/users/:user_id", deps.AdminConsole.UserDetail())
|
||||||
|
group.POST("/users/:user_id/block", deps.AdminConsole.UserBlock())
|
||||||
|
group.POST("/users/:user_id/entitlement", deps.AdminConsole.UserEntitlement())
|
||||||
|
group.POST("/users/:user_id/soft-delete", deps.AdminConsole.UserSoftDelete())
|
||||||
|
|
||||||
|
group.GET("/games", deps.AdminConsole.GamesList())
|
||||||
|
group.POST("/games", deps.AdminConsole.GameCreate())
|
||||||
|
group.GET("/games/:game_id", deps.AdminConsole.GameDetail())
|
||||||
|
group.POST("/games/:game_id/force-start", deps.AdminConsole.GameForceStart())
|
||||||
|
group.POST("/games/:game_id/force-stop", deps.AdminConsole.GameForceStop())
|
||||||
|
group.POST("/games/:game_id/ban-member", deps.AdminConsole.GameBanMember())
|
||||||
|
group.POST("/games/:game_id/runtime/restart", deps.AdminConsole.RuntimeRestart())
|
||||||
|
group.POST("/games/:game_id/runtime/patch", deps.AdminConsole.RuntimePatch())
|
||||||
|
group.POST("/games/:game_id/runtime/force-next-turn", deps.AdminConsole.RuntimeForceNextTurn())
|
||||||
|
|
||||||
|
group.GET("/engine-versions", deps.AdminConsole.EngineVersionsList())
|
||||||
|
group.POST("/engine-versions", deps.AdminConsole.EngineVersionRegister())
|
||||||
|
group.POST("/engine-versions/:version/disable", deps.AdminConsole.EngineVersionDisable())
|
||||||
|
|
||||||
|
group.GET("/operators", deps.AdminConsole.OperatorsList())
|
||||||
|
group.POST("/operators", deps.AdminConsole.OperatorCreate())
|
||||||
|
group.POST("/operators/:username/disable", deps.AdminConsole.OperatorDisable())
|
||||||
|
group.POST("/operators/:username/enable", deps.AdminConsole.OperatorEnable())
|
||||||
|
group.POST("/operators/:username/reset-password", deps.AdminConsole.OperatorResetPassword())
|
||||||
|
|
||||||
|
group.GET("/mail", deps.AdminConsole.MailPage())
|
||||||
|
group.GET("/mail/deliveries/:delivery_id", deps.AdminConsole.MailDeliveryDetail())
|
||||||
|
group.POST("/mail/deliveries/:delivery_id/resend", deps.AdminConsole.MailResend())
|
||||||
|
group.GET("/notifications", deps.AdminConsole.NotificationsPage())
|
||||||
|
group.GET("/broadcast", deps.AdminConsole.BroadcastForm())
|
||||||
|
group.POST("/broadcast", deps.AdminConsole.BroadcastSend())
|
||||||
|
}
|
||||||
|
|
||||||
// allowedMethodsForPath returns the comma-separated list of methods
|
// allowedMethodsForPath returns the comma-separated list of methods
|
||||||
// the gin router accepts on requestPath. Only the probe paths declare
|
// the gin router accepts on requestPath. Only the probe paths declare
|
||||||
// a non-empty list so NoMethod can advertise a useful `Allow` header
|
// a non-empty list so NoMethod can advertise a useful `Allow` header
|
||||||
|
|||||||
+45
-1
@@ -581,6 +581,36 @@ directly.
|
|||||||
`/api/v1/admin/notifications/*`) reuse the per-domain logic of the
|
`/api/v1/admin/notifications/*`) reuse the per-domain logic of the
|
||||||
module they target.
|
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.
|
||||||
|
|
||||||
|
The console landing page is a dashboard that surfaces backend-visible
|
||||||
|
operational signals — database reachability, per-status game-runtime counts,
|
||||||
|
and mail/notification queue depths — read directly through the persistence
|
||||||
|
layer; richer historical metrics come from the Prometheus exporters on
|
||||||
|
`backend` and `gateway` (see [§17](#17-observability)). See
|
||||||
|
`backend/docs/admin-console.md` for the console design.
|
||||||
|
|
||||||
## 15. Transport Security Model (gateway boundary)
|
## 15. Transport Security Model (gateway boundary)
|
||||||
|
|
||||||
This section describes the secure exchange model between client and
|
This section describes the secure exchange model between client and
|
||||||
@@ -823,7 +853,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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| Engine API authentication | network | Engine listens only on the trusted network; backend is the only caller. |
|
||||||
|
|
||||||
### Backend ↔ Gateway trust
|
### Backend ↔ Gateway trust
|
||||||
@@ -857,6 +888,19 @@ addition.
|
|||||||
- Health probes are unauthenticated `GET /healthz` (process liveness) and
|
- Health probes are unauthenticated `GET /healthz` (process liveness) and
|
||||||
`GET /readyz` (Postgres reachable, migrations applied, gRPC listener
|
`GET /readyz` (Postgres reachable, migrations applied, gRPC listener
|
||||||
bound). Probes are excluded from anti-replay and rate limiting.
|
bound). Probes are excluded from anti-replay and rate limiting.
|
||||||
|
- **Collection (dev, production mirror).** The long-lived dev environment
|
||||||
|
(`tools/dev-deploy/`) runs a full metrics + logs + traces stack on its
|
||||||
|
internal network with no host ports: Prometheus scrapes the backend
|
||||||
|
(`:9100`) and gateway (`:9191`) endpoints plus `node-exporter` and
|
||||||
|
cAdvisor; Tempo ingests OTLP traces from backend and gateway; Loki
|
||||||
|
stores container logs shipped by promtail (Docker service-discovery on
|
||||||
|
the `galaxy.stack=dev-deploy` label). Grafana (provisioned datasources
|
||||||
|
+ dashboards) and the Mailpit capture UI are reached only through the
|
||||||
|
operator console's single `/_gm` Basic Auth gate (§14.1) — at
|
||||||
|
`/_gm/grafana/` and `/_gm/mailpit/` — so one password covers the
|
||||||
|
console and both UIs. Retention is tuned small (Prometheus 15d, Loki
|
||||||
|
7d, Tempo 3d). The same compose fragment is meant to back production.
|
||||||
|
See `tools/dev-deploy/monitoring/README.md`.
|
||||||
|
|
||||||
## 18. CI and Environments
|
## 18. CI and Environments
|
||||||
|
|
||||||
|
|||||||
@@ -1162,6 +1162,31 @@ operator's password manager can match it across deployments.
|
|||||||
After the first deployment, the bootstrap password should be
|
After the first deployment, the bootstrap password should be
|
||||||
rotated through the admin surface.
|
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.
|
||||||
|
|
||||||
|
The console landing page is a dashboard that summarises operational
|
||||||
|
health: whether the backend is ready and the database reachable, how many
|
||||||
|
game runtimes sit in each state, and the depth of the mail and
|
||||||
|
notification queues. It is a read-only point-in-time view for quick
|
||||||
|
triage, not a metrics history. The console nav also links to Grafana
|
||||||
|
(metrics, logs and traces) and the Mailpit capture UI, which the
|
||||||
|
deployment serves under the same `/_gm` Basic Auth gate — one sign-in
|
||||||
|
covers the console and both UIs.
|
||||||
|
|
||||||
### 10.3 Admin account management
|
### 10.3 Admin account management
|
||||||
|
|
||||||
Existing admins can list other admins, create new ones, look up a
|
Existing admins can list other admins, create new ones, look up a
|
||||||
|
|||||||
@@ -1197,6 +1197,31 @@ deployments.
|
|||||||
После первого деплоя bootstrap-пароль должен быть ротирован
|
После первого деплоя bootstrap-пароль должен быть ротирован
|
||||||
через admin-surface.
|
через admin-surface.
|
||||||
|
|
||||||
|
### 10.2.1 Операторская консоль (`/_gm`)
|
||||||
|
|
||||||
|
Администраторы выполняют эти операции либо программно через JSON
|
||||||
|
admin-API, либо через серверно-рендеримую веб-консоль на `/_gm`.
|
||||||
|
Консоль аутентифицируется теми же Basic Auth-учётными данными:
|
||||||
|
открытие любой страницы `/_gm` вызывает нативный диалог браузера для
|
||||||
|
ввода учётных данных, и оператор остаётся залогинен на время сессии.
|
||||||
|
Навигация — обычными ссылками и query-параметрами; каждое изменение
|
||||||
|
отправляется формой и завершается редиректом обратно на затронутую
|
||||||
|
страницу.
|
||||||
|
|
||||||
|
Консоль — единственная admin-поверхность, достижимая извне
|
||||||
|
доверенной сети. Она проксируется через gateway, поэтому наследует те
|
||||||
|
же edge-rate-limiting и лимиты запросов, что и публичный API, и несёт
|
||||||
|
анти-CSRF-токен на каждом изменении. JSON admin-API остаётся
|
||||||
|
внутренним для деплоя.
|
||||||
|
|
||||||
|
Стартовая страница консоли — дашборд, сводящий операционное
|
||||||
|
здоровье: готов ли backend и доступна ли БД, сколько игровых рантаймов
|
||||||
|
в каждом состоянии, какова глубина очередей почты и уведомлений. Это
|
||||||
|
read-only-срез на текущий момент для быстрой диагностики, не история
|
||||||
|
метрик. Навигация консоли также ведёт в Grafana (метрики, логи и
|
||||||
|
трейсы) и в UI захвата почты Mailpit, которые деплой отдаёт под тем же
|
||||||
|
шлюзом Basic Auth `/_gm` — один вход покрывает консоль и оба UI.
|
||||||
|
|
||||||
### 10.3 Управление admin-аккаунтами
|
### 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
|
That traffic belongs to dedicated public route classes and must not share rate
|
||||||
limit buckets or abuse counters with the public auth API.
|
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
|
### Operational Admin Surface
|
||||||
|
|
||||||
The gateway may expose one private operational HTTP listener used for metrics.
|
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()},
|
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)
|
grpcDeps, components, cleanup, err := newAuthenticatedGRPCDependencies(ctx, cfg, logger, telemetryRuntime, backend)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = backend.Close()
|
_ = backend.Close()
|
||||||
|
|||||||
@@ -276,6 +276,22 @@ const (
|
|||||||
// configures the public_misc rate-limit burst.
|
// configures the public_misc rate-limit burst.
|
||||||
publicMiscRateLimitBurstEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_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
|
// sendEmailCodeIdentityRateLimitRequestsEnvVar names the environment
|
||||||
// variable that configures the send-email-code identity request budget per
|
// variable that configures the send-email-code identity request budget per
|
||||||
// window.
|
// window.
|
||||||
@@ -372,6 +388,14 @@ const (
|
|||||||
defaultPublicMiscRateLimitRequests = 30
|
defaultPublicMiscRateLimitRequests = 30
|
||||||
defaultPublicMiscRateLimitBurst = 10
|
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
|
defaultSendEmailCodeIdentityRateLimitRequests = 3
|
||||||
defaultSendEmailCodeIdentityRateLimitBurst = 1
|
defaultSendEmailCodeIdentityRateLimitBurst = 1
|
||||||
|
|
||||||
@@ -439,6 +463,11 @@ type PublicHTTPAntiAbuseConfig struct {
|
|||||||
// PublicMisc applies to the stable public_misc route class.
|
// PublicMisc applies to the stable public_misc route class.
|
||||||
PublicMisc PublicRoutePolicyConfig
|
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
|
// SendEmailCodeIdentity applies the additional identity limiter for
|
||||||
// send-email-code.
|
// send-email-code.
|
||||||
SendEmailCodeIdentity PublicAuthIdentityPolicyConfig
|
SendEmailCodeIdentity PublicAuthIdentityPolicyConfig
|
||||||
@@ -708,6 +737,14 @@ func DefaultPublicHTTPConfig() PublicHTTPConfig {
|
|||||||
Burst: defaultPublicMiscRateLimitBurst,
|
Burst: defaultPublicMiscRateLimitBurst,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Admin: PublicRoutePolicyConfig{
|
||||||
|
MaxBodyBytes: defaultAdminMaxBodyBytes,
|
||||||
|
RateLimit: PublicRateLimitConfig{
|
||||||
|
Requests: defaultAdminRateLimitRequests,
|
||||||
|
Window: defaultClassRateLimitWindow,
|
||||||
|
Burst: defaultAdminRateLimitBurst,
|
||||||
|
},
|
||||||
|
},
|
||||||
SendEmailCodeIdentity: PublicAuthIdentityPolicyConfig{
|
SendEmailCodeIdentity: PublicAuthIdentityPolicyConfig{
|
||||||
RateLimit: PublicRateLimitConfig{
|
RateLimit: PublicRateLimitConfig{
|
||||||
Requests: defaultSendEmailCodeIdentityRateLimitRequests,
|
Requests: defaultSendEmailCodeIdentityRateLimitRequests,
|
||||||
@@ -1092,6 +1129,18 @@ func LoadFromEnv() (Config, error) {
|
|||||||
}
|
}
|
||||||
cfg.PublicHTTP.AntiAbuse.PublicMisc = publicMiscPolicy
|
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(
|
sendIdentityPolicy, err := loadPublicAuthIdentityPolicyConfigFromEnv(
|
||||||
cfg.PublicHTTP.AntiAbuse.SendEmailCodeIdentity,
|
cfg.PublicHTTP.AntiAbuse.SendEmailCodeIdentity,
|
||||||
sendEmailCodeIdentityRateLimitRequestsEnvVar,
|
sendEmailCodeIdentityRateLimitRequestsEnvVar,
|
||||||
@@ -1247,6 +1296,9 @@ func LoadFromEnv() (Config, error) {
|
|||||||
if err := validatePublicRoutePolicyConfig(cfg.PublicHTTP.AntiAbuse.PublicMisc, publicMiscMaxBodyBytesEnvVar, publicMiscRateLimitRequestsEnvVar, publicMiscRateLimitWindowEnvVar, publicMiscRateLimitBurstEnvVar); err != nil {
|
if err := validatePublicRoutePolicyConfig(cfg.PublicHTTP.AntiAbuse.PublicMisc, publicMiscMaxBodyBytesEnvVar, publicMiscRateLimitRequestsEnvVar, publicMiscRateLimitWindowEnvVar, publicMiscRateLimitBurstEnvVar); err != nil {
|
||||||
return Config{}, err
|
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 {
|
if err := validatePublicAuthIdentityPolicyConfig(cfg.PublicHTTP.AntiAbuse.SendEmailCodeIdentity, sendEmailCodeIdentityRateLimitRequestsEnvVar, sendEmailCodeIdentityRateLimitWindowEnvVar, sendEmailCodeIdentityRateLimitBurstEnvVar); err != nil {
|
||||||
return Config{}, err
|
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
|
return policy.BrowserBootstrap
|
||||||
case PublicRouteClassBrowserAsset:
|
case PublicRouteClassBrowserAsset:
|
||||||
return policy.BrowserAsset
|
return policy.BrowserAsset
|
||||||
|
case PublicRouteClassAdmin:
|
||||||
|
return policy.Admin
|
||||||
default:
|
default:
|
||||||
return policy.PublicMisc
|
return policy.PublicMisc
|
||||||
}
|
}
|
||||||
@@ -252,6 +254,8 @@ func publicAuthIdentityPolicyForPath(requestPath string, policy config.PublicHTT
|
|||||||
|
|
||||||
func allowedMethodsForRequestShape(r *http.Request) []string {
|
func allowedMethodsForRequestShape(r *http.Request) []string {
|
||||||
switch {
|
switch {
|
||||||
|
case isAdminConsolePath(r.URL.Path):
|
||||||
|
return []string{http.MethodGet, http.MethodHead, http.MethodPost}
|
||||||
case isPublicAuthPath(r.URL.Path):
|
case isPublicAuthPath(r.URL.Path):
|
||||||
return []string{http.MethodPost}
|
return []string{http.MethodPost}
|
||||||
case isProbePath(r.URL.Path):
|
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 {
|
func isProbePath(requestPath string) bool {
|
||||||
switch requestPath {
|
switch requestPath {
|
||||||
case "/healthz", "/readyz":
|
case "/healthz", "/readyz":
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ const (
|
|||||||
// PublicRouteClassPublicMisc identifies public traffic that does not match a
|
// PublicRouteClassPublicMisc identifies public traffic that does not match a
|
||||||
// more specific class.
|
// more specific class.
|
||||||
PublicRouteClassPublicMisc PublicRouteClass = "public_misc"
|
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
|
var configureGinModeOnce sync.Once
|
||||||
@@ -60,6 +64,7 @@ func (c PublicRouteClass) Normalized() PublicRouteClass {
|
|||||||
case PublicRouteClassPublicAuth,
|
case PublicRouteClassPublicAuth,
|
||||||
PublicRouteClassBrowserBootstrap,
|
PublicRouteClassBrowserBootstrap,
|
||||||
PublicRouteClassBrowserAsset,
|
PublicRouteClassBrowserAsset,
|
||||||
|
PublicRouteClassAdmin,
|
||||||
PublicRouteClassPublicMisc:
|
PublicRouteClassPublicMisc:
|
||||||
return c
|
return c
|
||||||
default:
|
default:
|
||||||
@@ -110,6 +115,14 @@ type ServerDependencies struct {
|
|||||||
// Telemetry records low-cardinality edge metrics. When nil, metrics are
|
// Telemetry records low-cardinality edge metrics. When nil, metrics are
|
||||||
// disabled.
|
// disabled.
|
||||||
Telemetry *telemetry.Runtime
|
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.
|
// 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.
|
// later drive anti-abuse policy and rate limiting.
|
||||||
func (defaultPublicTrafficClassifier) Classify(r *http.Request) PublicRouteClass {
|
func (defaultPublicTrafficClassifier) Classify(r *http.Request) PublicRouteClass {
|
||||||
switch {
|
switch {
|
||||||
|
case isAdminConsoleRequest(r):
|
||||||
|
return PublicRouteClassAdmin
|
||||||
case isPublicAuthRequest(r):
|
case isPublicAuthRequest(r):
|
||||||
return PublicRouteClassPublicAuth
|
return PublicRouteClassPublicAuth
|
||||||
case isBrowserBootstrapRequest(r):
|
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/send-email-code", handleSendEmailCode(deps.AuthService, cfg.AuthUpstreamTimeout))
|
||||||
router.POST("/api/v1/public/auth/confirm-email-code", handleConfirmEmailCode(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) {
|
router.NoMethod(func(c *gin.Context) {
|
||||||
allowMethods := allowedMethodsForPath(c.Request.URL.Path)
|
allowMethods := allowedMethodsForPath(c.Request.URL.Path)
|
||||||
if allowMethods != "" {
|
if allowMethods != "" {
|
||||||
|
|||||||
@@ -169,6 +169,31 @@ func TestDefaultPublicTrafficClassifier(t *testing.T) {
|
|||||||
accept: "text/html",
|
accept: "text/html",
|
||||||
wantClass: PublicRouteClassBrowserBootstrap,
|
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 {
|
for _, tt := range tests {
|
||||||
@@ -215,6 +240,11 @@ func TestPublicRouteClassNormalized(t *testing.T) {
|
|||||||
input: PublicRouteClassPublicMisc,
|
input: PublicRouteClassPublicMisc,
|
||||||
want: PublicRouteClassPublicMisc,
|
want: PublicRouteClassPublicMisc,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "admin",
|
||||||
|
input: PublicRouteClassAdmin,
|
||||||
|
want: PublicRouteClassAdmin,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "unknown collapses to misc",
|
name: "unknown collapses to misc",
|
||||||
input: PublicRouteClass("unexpected"),
|
input: PublicRouteClass("unexpected"),
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package integration_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"galaxy/integration/testenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAdminConsole_ThroughGateway verifies the full operator-console pipe:
|
||||||
|
// the edge gateway reverse-proxies `/_gm/*` to the backend, the backend
|
||||||
|
// enforces the admin Basic Auth and relays its 401 challenge unchanged, and an
|
||||||
|
// authenticated request renders the server-side dashboard.
|
||||||
|
func TestAdminConsole_ThroughGateway(t *testing.T) {
|
||||||
|
plat := testenv.Bootstrap(t, testenv.BootstrapOptions{})
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
base := plat.Gateway.HTTPURL
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
// Unauthenticated: the backend's 401 + Basic challenge must reach the client
|
||||||
|
// through the gateway.
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/_gm/", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build request: %v", err)
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET /_gm/ (no auth): %v", err)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("no-auth status = %d, want 401; body=%s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Header.Get("WWW-Authenticate"), "Basic") {
|
||||||
|
t.Fatalf("WWW-Authenticate = %q, want a Basic challenge", resp.Header.Get("WWW-Authenticate"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated with the bootstrap operator: the dashboard renders.
|
||||||
|
req, err = http.NewRequestWithContext(ctx, http.MethodGet, base+"/_gm/", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build request: %v", err)
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(plat.Backend.AdminUser, plat.Backend.AdminPassword)
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET /_gm/ (auth): %v", err)
|
||||||
|
}
|
||||||
|
body, _ = io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("authenticated status = %d, want 200; body=%s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), "Dashboard") {
|
||||||
|
t.Fatalf("dashboard body missing the heading; got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,6 @@
|
|||||||
# baked into `docker-compose.yml`, so this file documents the knobs
|
# baked into `docker-compose.yml`, so this file documents the knobs
|
||||||
# rather than driving them.
|
# rather than driving them.
|
||||||
|
|
||||||
# Auto-provisioned sandbox bootstrap. Empty disables the bootstrap.
|
|
||||||
BACKEND_DEV_SANDBOX_EMAIL=dev@galaxy.lan
|
|
||||||
BACKEND_DEV_SANDBOX_ENGINE_IMAGE=galaxy-engine:dev
|
|
||||||
BACKEND_DEV_SANDBOX_ENGINE_VERSION=0.1.0
|
|
||||||
BACKEND_DEV_SANDBOX_PLAYER_COUNT=20
|
|
||||||
|
|
||||||
# `123456` short-circuits the email-code path for the dev account.
|
# `123456` short-circuits the email-code path for the dev account.
|
||||||
# This is also the docker-compose default — set the variable to an
|
# This is also the docker-compose default — set the variable to an
|
||||||
# empty string here when the environment must rely on real Mailpit
|
# empty string here when the environment must rely on real Mailpit
|
||||||
|
|||||||
@@ -29,6 +29,36 @@
|
|||||||
reverse_proxy galaxy-api:8080
|
reverse_proxy galaxy-api:8080
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Operator console + observability behind one Basic Auth gate. The gate
|
||||||
|
# credential equals the admin-console account (dev: gm / gm-dev-password),
|
||||||
|
# so Caddy forwards the same Authorization header to the backend `/_gm`
|
||||||
|
# surface (its own Basic Auth) and to Grafana/Mailpit — one prompt covers
|
||||||
|
# all three. The gateway applies the admin anti-abuse class to the console.
|
||||||
|
@gm path /_gm /_gm/*
|
||||||
|
handle @gm {
|
||||||
|
basic_auth {
|
||||||
|
gm "$2a$14$xVh1TLaZxh8fazlKrI9Mx.NQMQlMarYWtr3FRELmZIXuac/DeeTRO"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Grafana under /_gm/grafana/ (sub-path mode; anonymous Admin, so the
|
||||||
|
# /_gm gate is the only barrier — GF_AUTH_BASIC_ENABLED=false makes it
|
||||||
|
# ignore the forwarded Authorization header).
|
||||||
|
handle /_gm/grafana/* {
|
||||||
|
reverse_proxy galaxy-grafana:3000
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mailpit captured-mail UI under /_gm/mailpit/ (MP_WEBROOT). Shows
|
||||||
|
# every message the backend sent, relayed or not.
|
||||||
|
handle /_gm/mailpit/* {
|
||||||
|
reverse_proxy galaxy-mailpit:8025
|
||||||
|
}
|
||||||
|
|
||||||
|
# The operator console itself (gateway -> backend /_gm surface).
|
||||||
|
handle {
|
||||||
|
reverse_proxy galaxy-api:8080
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Bare `/game` (no trailing slash) -> `/game/` so the SPA root
|
# Bare `/game` (no trailing slash) -> `/game/` so the SPA root
|
||||||
# resolves before the site catch-all can claim it.
|
# resolves before the site catch-all can claim it.
|
||||||
handle /game {
|
handle /game {
|
||||||
|
|||||||
@@ -1,164 +1,8 @@
|
|||||||
# `tools/dev-deploy/` — known issues
|
# `tools/dev-deploy/` — known issues
|
||||||
|
|
||||||
Issues that surface in the long-lived dev environment but are not yet
|
Issues that surfaced in the long-lived dev environment. Each entry lists
|
||||||
fixed. Each entry lists the observed symptom, the diagnostic evidence,
|
the observed symptom, the diagnostic evidence, and the fix or the open
|
||||||
the working hypothesis, and the open questions that have to be
|
questions that have to be answered before a fix lands.
|
||||||
answered before a fix lands.
|
|
||||||
|
|
||||||
## Dev Sandbox game flips to `cancelled` after a `dev-deploy` redispatch
|
|
||||||
|
|
||||||
### Symptom
|
|
||||||
|
|
||||||
A previously `running` "Dev Sandbox" game (created by
|
|
||||||
`backend/internal/devsandbox`) transitions to `cancelled` ~15 minutes
|
|
||||||
after a `dev-deploy.yaml` workflow_dispatch run finishes. The user's
|
|
||||||
browser session survives (the same `device_session_id` keeps working),
|
|
||||||
but the lobby shows no game because the only game it had is now
|
|
||||||
terminal. `purgeTerminalSandboxGames` does pick it up on the **next**
|
|
||||||
boot and creates a fresh sandbox — but the first redispatch leaves
|
|
||||||
the user with an empty lobby until backend restarts again.
|
|
||||||
|
|
||||||
### Diagnostic evidence
|
|
||||||
|
|
||||||
Backend logs from the broken cycle (timestamps abbreviated):
|
|
||||||
|
|
||||||
```text
|
|
||||||
20:24:40 dev_sandbox: purged terminal sandbox game game_id=<prev> status=cancelled
|
|
||||||
20:24:40 dev_sandbox: memberships ensured count=20 game_id=<new>
|
|
||||||
20:24:40 dev_sandbox: bootstrap complete user_id=<owner> game_id=<new> status=starting
|
|
||||||
...
|
|
||||||
20:25:09 user mail sent failed (diplomail tables missing — unrelated)
|
|
||||||
...
|
|
||||||
20:39:40 lobby: game cancelled by runtime reconciler game_id=<new>
|
|
||||||
op=reconcile status=removed message="container disappeared"
|
|
||||||
```
|
|
||||||
|
|
||||||
Between 20:24:40 (`status=starting`) and 20:39:40 (reconciler cancel)
|
|
||||||
the backend logs are silent on the runtime / engine paths — no
|
|
||||||
`engine spawned`, no `engine container started`, no `runtime
|
|
||||||
transition` lines. The reconciler then fires and reports the engine
|
|
||||||
container as missing.
|
|
||||||
|
|
||||||
`docker ps -a --filter 'label=org.opencontainers.image.title=galaxy-game-engine'`
|
|
||||||
returns no rows during this window — the engine container is neither
|
|
||||||
running nor stopped on the host, so it either was never spawned or
|
|
||||||
was removed before the host snapshot.
|
|
||||||
|
|
||||||
### What has been ruled out
|
|
||||||
|
|
||||||
A live `docker inspect` on a healthy engine container shows:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Labels: galaxy.backend=1, galaxy.engine_version=0.1.0,
|
|
||||||
galaxy.game_id=<uuid>,
|
|
||||||
org.opencontainers.image.title=galaxy-game-engine,
|
|
||||||
com.galaxy.{cpu_quota,memory,pids_limit}
|
|
||||||
AutoRemove: false
|
|
||||||
RestartPolicy: on-failure
|
|
||||||
NetworkMode: galaxy-dev-internal
|
|
||||||
```
|
|
||||||
|
|
||||||
There are no `com.docker.compose.*` labels and `AutoRemove=false`,
|
|
||||||
so `--remove-orphans` cannot reap the engine and a `--rm`-style
|
|
||||||
self-destruct is not in play. Two redispatches captured under
|
|
||||||
`docker events --filter event=create,start,die,destroy,kill,stop`
|
|
||||||
also confirmed it: across both runs the only `die` / `destroy`
|
|
||||||
events were for `galaxy-dev-{backend,api,caddy}`. The live engine
|
|
||||||
container survived both redispatches, and the reconciler that
|
|
||||||
fires 60 seconds after the new backend boots correctly matched
|
|
||||||
it through `byGameID` / `byContainerID`.
|
|
||||||
|
|
||||||
`backend/internal/runtime/service.go` only removes engine
|
|
||||||
containers from the explicit `runStop` / `runRestart` / `runPatch`
|
|
||||||
paths. There is no `runtime.Service.Shutdown` that proactively
|
|
||||||
kills containers on backend exit, so a graceful SIGTERM to
|
|
||||||
`galaxy-dev-backend` will not touch its child engine containers.
|
|
||||||
|
|
||||||
### Host-side hypotheses considered and rejected by the owner
|
|
||||||
|
|
||||||
The natural follow-up suspects after compose was cleared — host-side
|
|
||||||
`docker prune` cron jobs, a manual `docker rm`, an out-of-band
|
|
||||||
`dockerd` restart, and an idle-state engine crash — were all
|
|
||||||
rejected by the project owner: the dev host runs none of those
|
|
||||||
periodic cleanups, no one manually removed the container, dockerd
|
|
||||||
was not restarted in the window, and the engine binary does not
|
|
||||||
crash while idling on API calls.
|
|
||||||
|
|
||||||
### Best remaining suspicion
|
|
||||||
|
|
||||||
Something the `dev-deploy.yaml` CI run does between successful
|
|
||||||
image builds and the final `docker compose up -d --wait
|
|
||||||
--remove-orphans` clobbers the previously-spawned engine container.
|
|
||||||
The chain at runtime contains:
|
|
||||||
|
|
||||||
1. `docker build -t galaxy-engine:dev -f game/Dockerfile .`
|
|
||||||
2. `docker compose build galaxy-backend galaxy-api`
|
|
||||||
3. `docker run --rm` alpine for the UI volume seed
|
|
||||||
4. `docker compose up -d --wait --remove-orphans`
|
|
||||||
|
|
||||||
None of these *should* touch an unmanaged engine container, but
|
|
||||||
the reproduction window points squarely inside this sequence. A
|
|
||||||
deliberate next reproduction with `docker events --since 0` armed
|
|
||||||
*before* the deploy starts and live for the entire job — captured
|
|
||||||
end-to-end on the dev host, not just the chunk after backend
|
|
||||||
recreate — would pin which step emits the `destroy` on the engine.
|
|
||||||
|
|
||||||
### Update 2026-05-19: integration preclean identified as one cause
|
|
||||||
|
|
||||||
A live reproduction during the post-merge auto-deploy cycle (Gitea
|
|
||||||
run #188 dev-deploy plus parallel run #190 integration) pinned one
|
|
||||||
clobbering source: `integration/scripts/preclean.sh` was unscoped
|
|
||||||
and removed *every* container labelled `galaxy.backend=1`, including
|
|
||||||
the dev-deploy engine. Timeline from the dev host:
|
|
||||||
|
|
||||||
```text
|
|
||||||
23:10:40 backend pre-bootstrap reconciler tick: engine alive
|
|
||||||
23:10:40 dev_sandbox bootstrap: status=running
|
|
||||||
23:10:56 preclean: removing 1 backend-managed engine containers ← integration run #190
|
|
||||||
23:11:40 reconciler: container disappeared → game cancelled
|
|
||||||
```
|
|
||||||
|
|
||||||
Fix landed: `BACKEND_STACK_LABEL=integration` is now passed to
|
|
||||||
every integration backend (see
|
|
||||||
`integration/testenv/backend.go`) and `preclean.sh` AND-combines
|
|
||||||
`galaxy.backend=1` with `galaxy.stack=integration`, so dev-deploy /
|
|
||||||
local-dev engines stamped with different stack values are no longer
|
|
||||||
collateral.
|
|
||||||
|
|
||||||
This covers **push**-triggered cycles where `dev-deploy.yaml` and
|
|
||||||
`integration.yaml` run on the same Gitea host. The original
|
|
||||||
hypothesis (a `workflow_dispatch dev-deploy` solo run also losing
|
|
||||||
the engine) is *not* explained by the integration fix — manual
|
|
||||||
dispatches do not trigger `integration.yaml`. Keep this entry open
|
|
||||||
until a solo-dispatch reproduction confirms whether the symptom
|
|
||||||
still occurs.
|
|
||||||
|
|
||||||
### Status
|
|
||||||
|
|
||||||
Partially fixed (push-triggered cycles). Solo `workflow_dispatch`
|
|
||||||
reproductions still open. If the symptom recurs after the
|
|
||||||
integration fix lands, capture `docker events --since 0` for the
|
|
||||||
full dispatch window and attach here.
|
|
||||||
|
|
||||||
### Workaround in use today
|
|
||||||
|
|
||||||
When the sandbox game flips to `cancelled`, redispatch `dev-deploy`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl -X POST -n -H 'Content-Type: application/json' \
|
|
||||||
-d '{"ref":"<branch>"}' \
|
|
||||||
https://gitea.iliadenisov.ru/api/v1/repos/developer/galaxy-game/actions/workflows/dev-deploy.yaml/dispatches
|
|
||||||
```
|
|
||||||
|
|
||||||
The next boot's `purgeTerminalSandboxGames` removes the cancelled
|
|
||||||
row, `findOrCreateSandboxGame` creates a fresh one, and
|
|
||||||
`ensureMembershipsAndDrive` puts the new game back to `running`.
|
|
||||||
|
|
||||||
### Owner
|
|
||||||
|
|
||||||
Unassigned. File an issue once we have the runtime / reconciler
|
|
||||||
analysis above; reference this section in the issue body so future
|
|
||||||
redeploys can short-circuit the diagnostic loop.
|
|
||||||
|
|
||||||
## `docker restart galaxy-dev-backend` fails after the CI runner cleans up
|
## `docker restart galaxy-dev-backend` fails after the CI runner cleans up
|
||||||
|
|
||||||
|
|||||||
+79
-19
@@ -114,17 +114,72 @@ calls `make clean-data`.
|
|||||||
The same dev-mode email-code override as `tools/local-dev/` applies,
|
The same dev-mode email-code override as `tools/local-dev/` applies,
|
||||||
and the dev-deploy compose ships with it enabled by default:
|
and the dev-deploy compose ships with it enabled by default:
|
||||||
|
|
||||||
1. Enter `dev@galaxy.lan` (or whatever `BACKEND_DEV_SANDBOX_EMAIL`
|
1. Enter your email address in the login form.
|
||||||
resolves to) in the login form.
|
|
||||||
2. Submit `123456` as the code — the docker-compose default for
|
2. Submit `123456` as the code — the docker-compose default for
|
||||||
`BACKEND_AUTH_DEV_FIXED_CODE` is `123456`, so the bcrypt-hashed
|
`BACKEND_AUTH_DEV_FIXED_CODE` is `123456`, so the bcrypt-hashed
|
||||||
email code stays a fallback. To force real Mailpit codes (e.g. for
|
email code stays a fallback. To force the real email code (which
|
||||||
mail-flow QA), set `BACKEND_AUTH_DEV_FIXED_CODE=` (empty) in a
|
Mailpit then relays to your Gmail — see **Mail** below), set
|
||||||
local `.env` and `make rebuild`.
|
`BACKEND_AUTH_DEV_FIXED_CODE=` (empty) and redeploy.
|
||||||
|
|
||||||
The fixed-code override is rejected by production env loaders, so it
|
The fixed-code override is rejected by production env loaders, so it
|
||||||
cannot leak into the prod environment.
|
cannot leak into the prod environment.
|
||||||
|
|
||||||
|
## Mail
|
||||||
|
|
||||||
|
The backend always submits mail to **Mailpit** (`galaxy-mailpit:1025`),
|
||||||
|
exactly as it would to a production SMTP server. Mailpit captures every
|
||||||
|
message in its UI (internal `:8025`) and, when configured, **relays**
|
||||||
|
the ones whose recipient matches `GALAXY_DEV_MAIL_RELAY_MATCH` up to a
|
||||||
|
real Gmail account — so an OTP addressed to you lands in your real inbox
|
||||||
|
while everything else stays captured-only.
|
||||||
|
|
||||||
|
Configure the relay through Gitea Actions secrets/vars (never
|
||||||
|
committed); the `dev-deploy.yaml` workflow renders Mailpit's
|
||||||
|
`relay.conf` (from `tools/dev-deploy/mailpit/relay.conf.tmpl`) and seeds
|
||||||
|
it into the `galaxy-dev-mailpit-config` volume:
|
||||||
|
|
||||||
|
| Name | Kind | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GALAXY_DEV_MAIL_RELAY_USERNAME` | secret | Gmail address used as the relay login + From. |
|
||||||
|
| `GALAXY_DEV_MAIL_RELAY_PASSWORD` | secret | Gmail **App Password** (requires 2FA; not the account password). |
|
||||||
|
| `GALAXY_DEV_MAIL_RELAY_MATCH` | var | Recipient regex to auto-relay (e.g. your Gmail address). Unset → capture-only. |
|
||||||
|
|
||||||
|
With none set the stack only captures mail (the compose relay-match
|
||||||
|
defaults to a non-routable address), so it can never email third
|
||||||
|
parties.
|
||||||
|
|
||||||
|
The capture UI is exposed through the operator console's `/_gm` gate at
|
||||||
|
[`/_gm/mailpit/`](https://galaxy.lan/_gm/mailpit/) — one Basic Auth for
|
||||||
|
the console, Grafana and Mailpit (see **Observability**). It shows
|
||||||
|
**every** message the backend sent, relayed or not, so you can read any
|
||||||
|
account's OTP regardless of the relay-match. For multi-account testing:
|
||||||
|
register several `you+tag@gmail.com` aliases and widen the match to a
|
||||||
|
regex such as `^you(\+[^@]+)?@gmail\.com$` (Gmail folds every `+tag`
|
||||||
|
into one inbox), or just read the codes in the Mailpit UI, or skip mail
|
||||||
|
entirely with the `123456` dev-code.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
A full metrics + logs + traces stack runs alongside the app on the
|
||||||
|
internal network (no host ports), as a production mirror. **Grafana**
|
||||||
|
and the **Mailpit** UI are reached only through the operator console's
|
||||||
|
single `/_gm` Basic Auth gate — one password (the admin-console account)
|
||||||
|
unlocks the console, [`/_gm/grafana/`](https://galaxy.lan/_gm/grafana/)
|
||||||
|
and [`/_gm/mailpit/`](https://galaxy.lan/_gm/mailpit/), with links in the
|
||||||
|
console nav. Grafana runs anonymous-Admin behind the gate (no own
|
||||||
|
login); Prometheus, Loki and Tempo stay internal-only.
|
||||||
|
|
||||||
|
- **Metrics** — Prometheus scrapes backend, gateway, `node-exporter` and
|
||||||
|
cAdvisor.
|
||||||
|
- **Logs** — promtail → Loki (Docker SD on the `galaxy.stack=dev-deploy`
|
||||||
|
label).
|
||||||
|
- **Traces** — backend + gateway → Tempo over OTLP.
|
||||||
|
|
||||||
|
Grafana's admin user is seeded from `GALAXY_DEV_GRAFANA_ADMIN_PASSWORD`
|
||||||
|
(for provisioning/API; the UI needs no Grafana login). See
|
||||||
|
[`monitoring/README.md`](monitoring/README.md) for services, configs and
|
||||||
|
tuning knobs.
|
||||||
|
|
||||||
## Networking
|
## Networking
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -139,6 +194,8 @@ galaxy-caddy (networks: edge + galaxy-dev-internal)
|
|||||||
│ /game/* -> file_server /srv/galaxy-ui (volume galaxy-dev-ui-dist)
|
│ /game/* -> file_server /srv/galaxy-ui (volume galaxy-dev-ui-dist)
|
||||||
│ /api/*, /healthz -> reverse_proxy galaxy-api:8080
|
│ /api/*, /healthz -> reverse_proxy galaxy-api:8080
|
||||||
│ /rpc/* -> reverse_proxy galaxy-api:9090 (strips /rpc)
|
│ /rpc/* -> reverse_proxy galaxy-api:9090 (strips /rpc)
|
||||||
|
│ /_gm, /_gm/* -> reverse_proxy galaxy-api:8080 (Basic Auth gate;
|
||||||
|
│ /_gm/grafana/ -> grafana, /_gm/mailpit/ -> mailpit)
|
||||||
▼
|
▼
|
||||||
galaxy-dev-internal
|
galaxy-dev-internal
|
||||||
├─ galaxy-api (gateway: :8080 REST, :9090 gRPC)
|
├─ galaxy-api (gateway: :8080 REST, :9090 gRPC)
|
||||||
@@ -146,7 +203,9 @@ galaxy-dev-internal
|
|||||||
├─ galaxy-postgres (postgres: :5432)
|
├─ galaxy-postgres (postgres: :5432)
|
||||||
├─ galaxy-redis (redis: :6379)
|
├─ galaxy-redis (redis: :6379)
|
||||||
├─ galaxy-mailpit (mailpit: :8025 UI, :1025 SMTP)
|
├─ galaxy-mailpit (mailpit: :8025 UI, :1025 SMTP)
|
||||||
└─ engine containers (spawned by backend on demand)
|
├─ engine containers (spawned by backend on demand)
|
||||||
|
└─ observability (prometheus, grafana, loki, promtail, tempo,
|
||||||
|
node-exporter, cadvisor)
|
||||||
```
|
```
|
||||||
|
|
||||||
The compose project deliberately exposes no host ports. Diagnostics
|
The compose project deliberately exposes no host ports. Diagnostics
|
||||||
@@ -191,8 +250,10 @@ make clean-data Stop everything and wipe volumes + game-state dir
|
|||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
- `docker-compose.yml` — six services: postgres, redis, mailpit,
|
- `docker-compose.yml` — the application services (postgres, redis,
|
||||||
galaxy-backend, galaxy-api, galaxy-caddy. `galaxy-caddy` mounts both
|
mailpit, galaxy-backend, galaxy-api, galaxy-caddy) plus the
|
||||||
|
observability stack (prometheus, grafana, loki, promtail, tempo,
|
||||||
|
node-exporter, cadvisor). `galaxy-caddy` mounts both
|
||||||
the `galaxy-dev-site-dist` (`/srv/galaxy-site`) and
|
the `galaxy-dev-site-dist` (`/srv/galaxy-site`) and
|
||||||
`galaxy-dev-ui-dist` (`/srv/galaxy-ui`) volumes and reverse-proxies
|
`galaxy-dev-ui-dist` (`/srv/galaxy-ui`) volumes and reverse-proxies
|
||||||
both gateway tiers (REST/health on `:8080`, Connect/gRPC-web on
|
both gateway tiers (REST/health on `:8080`, Connect/gRPC-web on
|
||||||
@@ -204,6 +265,8 @@ make clean-data Stop everything and wipe volumes + game-state dir
|
|||||||
at `/etc/caddy/Caddyfile`.
|
at `/etc/caddy/Caddyfile`.
|
||||||
- `Caddyfile.prod` — placeholder for a future prod deployment; not used
|
- `Caddyfile.prod` — placeholder for a future prod deployment; not used
|
||||||
by this compose.
|
by this compose.
|
||||||
|
- `monitoring/` — Prometheus / Loki / promtail / Tempo / Grafana
|
||||||
|
configuration, provisioned as code; see `monitoring/README.md`.
|
||||||
- `Makefile` — wrapper over `docker compose` with helpers for engine,
|
- `Makefile` — wrapper over `docker compose` with helpers for engine,
|
||||||
site/UI seeding, health probes, and full wipe.
|
site/UI seeding, health probes, and full wipe.
|
||||||
- `.env.example` — non-secret defaults for the compose `${VAR:-}`
|
- `.env.example` — non-secret defaults for the compose `${VAR:-}`
|
||||||
@@ -212,8 +275,7 @@ make clean-data Stop everything and wipe volumes + game-state dir
|
|||||||
## Known issues
|
## Known issues
|
||||||
|
|
||||||
See [`KNOWN-ISSUES.md`](KNOWN-ISSUES.md) for symptoms that surface
|
See [`KNOWN-ISSUES.md`](KNOWN-ISSUES.md) for symptoms that surface
|
||||||
in the long-lived dev environment but are not yet fixed (currently:
|
in the long-lived dev environment but are not yet fixed.
|
||||||
the sandbox game flipping to `cancelled` after a redispatch).
|
|
||||||
|
|
||||||
## Deployment cadence
|
## Deployment cadence
|
||||||
|
|
||||||
@@ -237,12 +299,12 @@ behind. There is no separate state to clean up between the two paths.
|
|||||||
|
|
||||||
### Engine image drift recycle
|
### Engine image drift recycle
|
||||||
|
|
||||||
`backend` spawns one engine container per game (the long-lived "Dev
|
`backend` spawns one engine container per running game and the
|
||||||
Sandbox" plus any user-created games) and the reconciler reattaches
|
reconciler reattaches to whatever it finds with the
|
||||||
to whatever it finds with the `galaxy.stack=dev-deploy` label. That
|
`galaxy.stack=dev-deploy` label. That reattach does not check the
|
||||||
reattach does not check the running container's image SHA against the
|
running container's image SHA against the freshly-built
|
||||||
freshly-built `galaxy-engine:dev` tag, so an unchanged container would
|
`galaxy-engine:dev` tag, so an unchanged container would otherwise
|
||||||
otherwise keep serving the previous engine code after a redeploy.
|
keep serving the previous engine code after a redeploy.
|
||||||
|
|
||||||
The `dev-deploy.yaml` workflow handles this in the
|
The `dev-deploy.yaml` workflow handles this in the
|
||||||
`Recycle engine containers on image drift` step. When `docker build`
|
`Recycle engine containers on image drift` step. When `docker build`
|
||||||
@@ -250,9 +312,7 @@ produces a new `galaxy-engine:dev` SHA, the step compares it against
|
|||||||
every running `galaxy-game-*` container and, for each drifted one,
|
every running `galaxy-game-*` container and, for each drifted one,
|
||||||
stops the backend, removes the container, wipes its bind-mounted
|
stops the backend, removes the container, wipes its bind-mounted
|
||||||
state directory (Engine.Init() writes turn-0 over any pre-existing
|
state directory (Engine.Init() writes turn-0 over any pre-existing
|
||||||
`turn-N` files), and cascade-deletes the lobby `games` row. The
|
`turn-N` files), and cascade-deletes the lobby `games` row.
|
||||||
`dev-sandbox` bootstrap on the next backend boot finds no live
|
|
||||||
sandbox and provisions a fresh one on the new engine image.
|
|
||||||
|
|
||||||
When the engine sources are unchanged, the BuildKit cache hits and
|
When the engine sources are unchanged, the BuildKit cache hits and
|
||||||
the SHA stays the same — the recycle step is a no-op and the running
|
the SHA stays the same — the recycle step is a no-op and the running
|
||||||
|
|||||||
@@ -66,12 +66,26 @@ services:
|
|||||||
image: axllent/mailpit:v1.21
|
image: axllent/mailpit:v1.21
|
||||||
container_name: galaxy-dev-mailpit
|
container_name: galaxy-dev-mailpit
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# Mailpit is both the SMTP submission point and a relay: it captures
|
||||||
|
# every message in its UI and auto-relays the ones whose recipient
|
||||||
|
# matches GALAXY_DEV_MAIL_RELAY_MATCH to the Gmail account in the
|
||||||
|
# secret-rendered relay config. The default match is non-routable, so
|
||||||
|
# a stack brought up without the relay secret only captures, never sends.
|
||||||
|
command:
|
||||||
|
- "--smtp-relay-config=/etc/mailpit/relay.conf"
|
||||||
|
- "--smtp-relay-matching=${GALAXY_DEV_MAIL_RELAY_MATCH:-nobody@invalid.example}"
|
||||||
|
# Serve the capture UI under /_gm/mailpit so the host Caddy can expose
|
||||||
|
# it at https://galaxy.lan/_gm/mailpit/ behind the shared /_gm gate;
|
||||||
|
# SMTP is unaffected.
|
||||||
|
- "--webroot=/_gm/mailpit"
|
||||||
labels:
|
labels:
|
||||||
galaxy.stack: dev-deploy
|
galaxy.stack: dev-deploy
|
||||||
networks:
|
networks:
|
||||||
- galaxy-internal
|
- galaxy-internal
|
||||||
|
volumes:
|
||||||
|
- galaxy-dev-mailpit-config:/etc/mailpit:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "-O-", "http://localhost:8025/livez"]
|
test: ["CMD", "wget", "-q", "-O-", "http://localhost:8025/_gm/mailpit/livez"]
|
||||||
interval: 3s
|
interval: 3s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 30
|
retries: 30
|
||||||
@@ -108,23 +122,31 @@ services:
|
|||||||
BACKEND_NOTIFICATION_ADMIN_EMAIL: admin@galaxy.lan
|
BACKEND_NOTIFICATION_ADMIN_EMAIL: admin@galaxy.lan
|
||||||
BACKEND_MAIL_WORKER_INTERVAL: 500ms
|
BACKEND_MAIL_WORKER_INTERVAL: 500ms
|
||||||
BACKEND_NOTIFICATION_WORKER_INTERVAL: 500ms
|
BACKEND_NOTIFICATION_WORKER_INTERVAL: 500ms
|
||||||
BACKEND_OTEL_TRACES_EXPORTER: none
|
BACKEND_OTEL_TRACES_EXPORTER: otlp
|
||||||
BACKEND_OTEL_METRICS_EXPORTER: none
|
BACKEND_OTEL_PROTOCOL: grpc
|
||||||
|
BACKEND_OTEL_ENDPOINT: "galaxy-tempo:4317"
|
||||||
|
# Tempo's OTLP receiver is plaintext on the internal network; the
|
||||||
|
# backend's gRPC exporter defaults to TLS, so disable it via the
|
||||||
|
# standard SDK env (applied on top of WithEndpoint).
|
||||||
|
OTEL_EXPORTER_OTLP_INSECURE: "true"
|
||||||
|
# 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
|
# Long-lived dev environment always opts into the fixed-code
|
||||||
# override so a returning developer can sign in with `123456`
|
# override so a returning developer can sign in with `123456`
|
||||||
# even after the matching browser session was cleared (the real
|
# even after the matching browser session was cleared (the real
|
||||||
# bcrypt-hashed code is single-use). Set the var to an empty
|
# bcrypt-hashed code is single-use). Set the var to an empty
|
||||||
# string in `.env` to disable.
|
# string in `.env` to disable.
|
||||||
BACKEND_AUTH_DEV_FIXED_CODE: ${BACKEND_AUTH_DEV_FIXED_CODE:-123456}
|
BACKEND_AUTH_DEV_FIXED_CODE: ${BACKEND_AUTH_DEV_FIXED_CODE:-123456}
|
||||||
# Long-lived dev environment always bootstraps the "Dev Sandbox"
|
|
||||||
# game owned by this email so a freshly redeployed stack already
|
|
||||||
# has one ready-to-play game in the lobby. Set the variable to an
|
|
||||||
# empty string in `.env` to disable the bootstrap (e.g. for a
|
|
||||||
# cold-start QA pass).
|
|
||||||
BACKEND_DEV_SANDBOX_EMAIL: ${BACKEND_DEV_SANDBOX_EMAIL:-dev@galaxy.lan}
|
|
||||||
BACKEND_DEV_SANDBOX_ENGINE_IMAGE: ${BACKEND_DEV_SANDBOX_ENGINE_IMAGE:-galaxy-engine:dev}
|
|
||||||
BACKEND_DEV_SANDBOX_ENGINE_VERSION: ${BACKEND_DEV_SANDBOX_ENGINE_VERSION:-0.1.0}
|
|
||||||
BACKEND_DEV_SANDBOX_PLAYER_COUNT: ${BACKEND_DEV_SANDBOX_PLAYER_COUNT:-20}
|
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
# Per-game state directories live under the same absolute path
|
# Per-game state directories live under the same absolute path
|
||||||
@@ -180,6 +202,16 @@ services:
|
|||||||
GATEWAY_LOG_LEVEL: info
|
GATEWAY_LOG_LEVEL: info
|
||||||
GATEWAY_PUBLIC_HTTP_ADDR: ":8080"
|
GATEWAY_PUBLIC_HTTP_ADDR: ":8080"
|
||||||
GATEWAY_AUTHENTICATED_GRPC_ADDR: ":9090"
|
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"
|
||||||
|
# Traces -> Tempo over OTLP gRPC (plaintext on the internal net).
|
||||||
|
OTEL_SERVICE_NAME: galaxy-gateway
|
||||||
|
OTEL_TRACES_EXPORTER: otlp
|
||||||
|
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: "http://galaxy-tempo:4317"
|
||||||
|
OTEL_EXPORTER_OTLP_INSECURE: "true"
|
||||||
GATEWAY_BACKEND_HTTP_URL: "http://galaxy-backend:8080"
|
GATEWAY_BACKEND_HTTP_URL: "http://galaxy-backend:8080"
|
||||||
GATEWAY_BACKEND_GRPC_PUSH_URL: "galaxy-backend:8081"
|
GATEWAY_BACKEND_GRPC_PUSH_URL: "galaxy-backend:8081"
|
||||||
GATEWAY_BACKEND_GATEWAY_CLIENT_ID: dev-gateway-1
|
GATEWAY_BACKEND_GATEWAY_CLIENT_ID: dev-gateway-1
|
||||||
@@ -208,6 +240,9 @@ services:
|
|||||||
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_RATE_LIMIT_BURST: "1000"
|
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_BOOTSTRAP_MAX_BODY_BYTES: "65536"
|
||||||
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_ASSET_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_REQUESTS: "10000"
|
||||||
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_BURST: "1000"
|
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_BURST: "1000"
|
||||||
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_SESSION_RATE_LIMIT_REQUESTS: "10000"
|
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_SESSION_RATE_LIMIT_REQUESTS: "10000"
|
||||||
@@ -245,6 +280,163 @@ services:
|
|||||||
- galaxy-internal
|
- galaxy-internal
|
||||||
- edge
|
- edge
|
||||||
|
|
||||||
|
galaxy-prometheus:
|
||||||
|
image: prom/prometheus:v2.55.1
|
||||||
|
container_name: galaxy-dev-prometheus
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
galaxy.stack: dev-deploy
|
||||||
|
command:
|
||||||
|
- --config.file=/etc/prometheus/prometheus.yml
|
||||||
|
- --storage.tsdb.path=/prometheus
|
||||||
|
- --storage.tsdb.retention.time=15d
|
||||||
|
- --web.enable-lifecycle
|
||||||
|
volumes:
|
||||||
|
- ${GALAXY_DEV_MONITORING_DIR:-./monitoring}/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- galaxy-dev-prometheus-data:/prometheus
|
||||||
|
networks:
|
||||||
|
- galaxy-internal
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 384m
|
||||||
|
|
||||||
|
galaxy-loki:
|
||||||
|
image: grafana/loki:3.3.2
|
||||||
|
container_name: galaxy-dev-loki
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
galaxy.stack: dev-deploy
|
||||||
|
command: ["-config.file=/etc/loki/loki.yml"]
|
||||||
|
volumes:
|
||||||
|
- ${GALAXY_DEV_MONITORING_DIR:-./monitoring}/loki/loki.yml:/etc/loki/loki.yml:ro
|
||||||
|
- galaxy-dev-loki-data:/loki
|
||||||
|
networks:
|
||||||
|
- galaxy-internal
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 384m
|
||||||
|
|
||||||
|
galaxy-promtail:
|
||||||
|
image: grafana/promtail:3.3.2
|
||||||
|
container_name: galaxy-dev-promtail
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
galaxy.stack: dev-deploy
|
||||||
|
command: ["-config.file=/etc/promtail/promtail.yml"]
|
||||||
|
volumes:
|
||||||
|
- ${GALAXY_DEV_MONITORING_DIR:-./monitoring}/promtail/promtail.yml:/etc/promtail/promtail.yml:ro
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
networks:
|
||||||
|
- galaxy-internal
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 128m
|
||||||
|
|
||||||
|
galaxy-tempo:
|
||||||
|
image: grafana/tempo:2.7.1
|
||||||
|
container_name: galaxy-dev-tempo
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
galaxy.stack: dev-deploy
|
||||||
|
command: ["-config.file=/etc/tempo/tempo.yml"]
|
||||||
|
volumes:
|
||||||
|
- ${GALAXY_DEV_MONITORING_DIR:-./monitoring}/tempo/tempo.yml:/etc/tempo/tempo.yml:ro
|
||||||
|
- galaxy-dev-tempo-data:/var/tempo
|
||||||
|
networks:
|
||||||
|
- galaxy-internal
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 384m
|
||||||
|
|
||||||
|
galaxy-node-exporter:
|
||||||
|
image: prom/node-exporter:v1.8.2
|
||||||
|
container_name: galaxy-dev-node-exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
galaxy.stack: dev-deploy
|
||||||
|
command:
|
||||||
|
- --path.procfs=/host/proc
|
||||||
|
- --path.sysfs=/host/sys
|
||||||
|
- --path.rootfs=/rootfs
|
||||||
|
- --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)
|
||||||
|
volumes:
|
||||||
|
- /proc:/host/proc:ro
|
||||||
|
- /sys:/host/sys:ro
|
||||||
|
- /:/rootfs:ro
|
||||||
|
pid: host
|
||||||
|
networks:
|
||||||
|
- galaxy-internal
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 64m
|
||||||
|
|
||||||
|
galaxy-cadvisor:
|
||||||
|
image: gcr.io/cadvisor/cadvisor:v0.49.1
|
||||||
|
container_name: galaxy-dev-cadvisor
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
galaxy.stack: dev-deploy
|
||||||
|
command:
|
||||||
|
- --housekeeping_interval=30s
|
||||||
|
- --docker_only=true
|
||||||
|
- --store_container_labels=false
|
||||||
|
privileged: true
|
||||||
|
devices:
|
||||||
|
- /dev/kmsg
|
||||||
|
volumes:
|
||||||
|
- /:/rootfs:ro
|
||||||
|
- /var/run:/var/run:ro
|
||||||
|
- /sys:/sys:ro
|
||||||
|
- /var/lib/docker/:/var/lib/docker:ro
|
||||||
|
- /dev/disk/:/dev/disk:ro
|
||||||
|
networks:
|
||||||
|
- galaxy-internal
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256m
|
||||||
|
|
||||||
|
galaxy-grafana:
|
||||||
|
image: grafana/grafana:11.4.0
|
||||||
|
container_name: galaxy-dev-grafana
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
galaxy.stack: dev-deploy
|
||||||
|
depends_on:
|
||||||
|
- galaxy-prometheus
|
||||||
|
- galaxy-loki
|
||||||
|
- galaxy-tempo
|
||||||
|
environment:
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: ${GALAXY_DEV_GRAFANA_ADMIN_PASSWORD:-admin}
|
||||||
|
GF_SERVER_ROOT_URL: https://galaxy.lan/_gm/grafana/
|
||||||
|
GF_SERVER_SERVE_FROM_SUB_PATH: "true"
|
||||||
|
# No own login: the /_gm Basic Auth gate is the only barrier, so
|
||||||
|
# serve everyone as anonymous Admin and ignore the forwarded
|
||||||
|
# Authorization header (basic auth off, login form off).
|
||||||
|
GF_AUTH_ANONYMOUS_ENABLED: "true"
|
||||||
|
GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
|
||||||
|
GF_AUTH_DISABLE_LOGIN_FORM: "true"
|
||||||
|
GF_AUTH_BASIC_ENABLED: "false"
|
||||||
|
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||||
|
GF_ANALYTICS_REPORTING_ENABLED: "false"
|
||||||
|
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
|
||||||
|
GF_NEWS_NEWS_FEED_ENABLED: "false"
|
||||||
|
volumes:
|
||||||
|
- ${GALAXY_DEV_MONITORING_DIR:-./monitoring}/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
- ${GALAXY_DEV_MONITORING_DIR:-./monitoring}/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||||
|
- galaxy-dev-grafana-data:/var/lib/grafana
|
||||||
|
networks:
|
||||||
|
- galaxy-internal
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256m
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
galaxy-internal:
|
galaxy-internal:
|
||||||
name: galaxy-dev-internal
|
name: galaxy-dev-internal
|
||||||
@@ -274,3 +466,13 @@ volumes:
|
|||||||
name: galaxy-dev-site-dist
|
name: galaxy-dev-site-dist
|
||||||
galaxy-dev-geoip-data:
|
galaxy-dev-geoip-data:
|
||||||
name: galaxy-dev-geoip-data
|
name: galaxy-dev-geoip-data
|
||||||
|
galaxy-dev-mailpit-config:
|
||||||
|
name: galaxy-dev-mailpit-config
|
||||||
|
galaxy-dev-prometheus-data:
|
||||||
|
name: galaxy-dev-prometheus-data
|
||||||
|
galaxy-dev-grafana-data:
|
||||||
|
name: galaxy-dev-grafana-data
|
||||||
|
galaxy-dev-loki-data:
|
||||||
|
name: galaxy-dev-loki-data
|
||||||
|
galaxy-dev-tempo-data:
|
||||||
|
name: galaxy-dev-tempo-data
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Mailpit SMTP relay upstream — RENDERED AT DEPLOY TIME by
|
||||||
|
# .gitea/workflows/dev-deploy.yaml from Gitea Actions secrets, then
|
||||||
|
# seeded into the `galaxy-dev-mailpit-config` volume. The Gmail App
|
||||||
|
# Password is a secret and MUST NOT be committed: this template only
|
||||||
|
# carries ${PLACEHOLDER}s that the workflow substitutes. See
|
||||||
|
# tools/dev-deploy/README.md ("Mail").
|
||||||
|
#
|
||||||
|
# Mailpit captures every message; the `--smtp-relay-matching` flag (set
|
||||||
|
# from GALAXY_DEV_MAIL_RELAY_MATCH in the compose) decides which
|
||||||
|
# recipients are actually relayed up to this Gmail account.
|
||||||
|
host: smtp.gmail.com
|
||||||
|
port: 587
|
||||||
|
starttls: true
|
||||||
|
allow-insecure: false
|
||||||
|
auth: login
|
||||||
|
username: ${GALAXY_DEV_MAIL_RELAY_USERNAME}
|
||||||
|
password: ${GALAXY_DEV_MAIL_RELAY_PASSWORD}
|
||||||
|
return-path: ${GALAXY_DEV_MAIL_RELAY_USERNAME}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# `tools/dev-deploy/monitoring/` — observability stack
|
||||||
|
|
||||||
|
The long-lived dev environment runs a full metrics + logs + traces stack
|
||||||
|
alongside the application as a **production mirror**: the same compose
|
||||||
|
fragment and collector configs are meant to back production later. Every
|
||||||
|
collector lives on the internal `galaxy-dev-internal` network and
|
||||||
|
publishes **no host port**. The browser-reachable pieces (Grafana and
|
||||||
|
the Mailpit UI) sit behind the operator console's single `/_gm` Basic
|
||||||
|
Auth gate — see [`../README.md`](../README.md) and `ARCHITECTURE.md §14`.
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
| Service | Image | Role | Reachable |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `galaxy-prometheus` | `prom/prometheus` | Scrape + store metrics (15d) | internal `:9090` |
|
||||||
|
| `galaxy-loki` | `grafana/loki` | Log store (7d) | internal `:3100` |
|
||||||
|
| `galaxy-promtail` | `grafana/promtail` | Ship container logs to Loki | — |
|
||||||
|
| `galaxy-tempo` | `grafana/tempo` | Trace store (3d), OTLP receiver | internal `:3200`, OTLP `:4317`/`:4318` |
|
||||||
|
| `galaxy-node-exporter` | `prom/node-exporter` | Host metrics | internal `:9100` |
|
||||||
|
| `galaxy-cadvisor` | `cadvisor` | Per-container CPU/memory/IO | internal `:8080` |
|
||||||
|
| `galaxy-grafana` | `grafana/grafana` | Dashboards + Explore | Caddy `/_gm/grafana/` |
|
||||||
|
|
||||||
|
## What is collected
|
||||||
|
|
||||||
|
- **Metrics.** Prometheus (30s interval) scrapes the backend Prometheus
|
||||||
|
endpoint (`galaxy-backend:9100`), the gateway admin endpoint
|
||||||
|
(`galaxy-api:9191`), `node-exporter` (host) and cAdvisor (per
|
||||||
|
container). Engine containers expose no `/metrics`; cAdvisor covers
|
||||||
|
their resource use.
|
||||||
|
- **Logs.** promtail discovers containers through the Docker API,
|
||||||
|
filtered to the `galaxy.stack=dev-deploy` label, and ships their
|
||||||
|
stdout/stderr to Loki labelled by `container`.
|
||||||
|
- **Traces.** backend and gateway export OTLP traces over gRPC to Tempo
|
||||||
|
(`galaxy-tempo:4317`), plaintext on the internal network
|
||||||
|
(`OTEL_EXPORTER_OTLP_INSECURE=true`, since Tempo's receiver is not
|
||||||
|
TLS-wrapped inside the contour).
|
||||||
|
|
||||||
|
## Grafana access (behind the `/_gm` gate)
|
||||||
|
|
||||||
|
Grafana is served under `/_gm/grafana/` (`GF_SERVER_ROOT_URL` +
|
||||||
|
`GF_SERVER_SERVE_FROM_SUB_PATH=true`) **behind the shared operator gate**:
|
||||||
|
the Caddy `/_gm/*` Basic Auth (the admin-console account) is the only
|
||||||
|
barrier. Grafana itself runs as **anonymous Admin** with its login form
|
||||||
|
and basic auth disabled (`GF_AUTH_ANONYMOUS_ENABLED=true`,
|
||||||
|
`GF_AUTH_ANONYMOUS_ORG_ROLE=Admin`, `GF_AUTH_DISABLE_LOGIN_FORM=true`,
|
||||||
|
`GF_AUTH_BASIC_ENABLED=false`), so it ignores the forwarded credentials
|
||||||
|
and asks for no second password. `GALAXY_DEV_GRAFANA_ADMIN_PASSWORD`
|
||||||
|
still seeds the admin user for provisioning/API use.
|
||||||
|
|
||||||
|
Datasources (Prometheus, Loki, Tempo) and a starter dashboard
|
||||||
|
(`grafana/dashboards/galaxy-overview.json`) are provisioned as code under
|
||||||
|
`grafana/provisioning/`.
|
||||||
|
|
||||||
|
## Config delivery
|
||||||
|
|
||||||
|
`dev-deploy.yaml` copies this directory to a stable host path
|
||||||
|
(`$HOME/.galaxy-dev/monitoring`, exported as `GALAXY_DEV_MONITORING_DIR`)
|
||||||
|
before `compose up`, and the compose binds it read-only into the
|
||||||
|
collectors. A stable path — not the ephemeral CI workspace — keeps the
|
||||||
|
mounts valid across container restarts and host reboots (the same lesson
|
||||||
|
as the geoip volume; see `../KNOWN-ISSUES.md`).
|
||||||
|
|
||||||
|
## Tuning (cost knobs)
|
||||||
|
|
||||||
|
Defaults favour the smallest workable footprint; all are config/compose
|
||||||
|
values:
|
||||||
|
|
||||||
|
- Prometheus `scrape_interval=30s`, `--storage.tsdb.retention.time=15d`.
|
||||||
|
- Loki `retention_period=168h` (7d); Tempo `block_retention=72h` (3d).
|
||||||
|
- cAdvisor `--housekeeping_interval=30s`.
|
||||||
|
- Per-service `deploy.resources.limits.memory` caps (~1.5 GB total cap;
|
||||||
|
steady-state well under that).
|
||||||
|
|
||||||
|
Seven always-on containers cost roughly ~1.1 GB steady RAM and
|
||||||
|
~1.5–2.5 GB disk at these retention windows. cAdvisor is the main CPU
|
||||||
|
cost; on a constrained host it can be dropped (host + app metrics still
|
||||||
|
cover most needs).
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"annotations": { "list": [] },
|
||||||
|
"editable": true,
|
||||||
|
"graphTooltip": 0,
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] },
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
||||||
|
"id": 1,
|
||||||
|
"title": "Backend HTTP request rate",
|
||||||
|
"type": "timeseries",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"expr": "sum by (group) (rate(http_requests_total[5m]))",
|
||||||
|
"legendFormat": "{{group}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||||
|
"id": 2,
|
||||||
|
"title": "Container memory (cadvisor)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
|
"expr": "sum by (name) (container_memory_usage_bytes{name=~\"galaxy-dev-.*|galaxy-game-.*\"})",
|
||||||
|
"legendFormat": "{{name}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"tags": ["galaxy"],
|
||||||
|
"templating": { "list": [] },
|
||||||
|
"time": { "from": "now-6h", "to": "now" },
|
||||||
|
"timepicker": {},
|
||||||
|
"title": "Galaxy — overview",
|
||||||
|
"uid": "galaxy-overview",
|
||||||
|
"version": 1,
|
||||||
|
"weekStart": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Grafana dashboard provider: load every JSON under the mounted
|
||||||
|
# dashboards directory at startup (provisioned as code).
|
||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: galaxy
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
allowUiUpdates: true
|
||||||
|
options:
|
||||||
|
path: /var/lib/grafana/dashboards
|
||||||
|
foldersFromFilesStructure: false
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Grafana datasources provisioned as code (dev↔prod parity). All reach
|
||||||
|
# the collectors by Docker DNS (compose service names) on
|
||||||
|
# galaxy-dev-internal.
|
||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
uid: prometheus
|
||||||
|
url: http://galaxy-prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
- name: Loki
|
||||||
|
type: loki
|
||||||
|
access: proxy
|
||||||
|
uid: loki
|
||||||
|
url: http://galaxy-loki:3100
|
||||||
|
- name: Tempo
|
||||||
|
type: tempo
|
||||||
|
access: proxy
|
||||||
|
uid: tempo
|
||||||
|
url: http://galaxy-tempo:3200
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Single-binary Loki for the dev stack: filesystem storage, in-memory
|
||||||
|
# ring, 7-day retention. Internal-only (no host port).
|
||||||
|
auth_enabled: false
|
||||||
|
|
||||||
|
server:
|
||||||
|
http_listen_port: 3100
|
||||||
|
grpc_listen_port: 9095
|
||||||
|
log_level: warn
|
||||||
|
|
||||||
|
common:
|
||||||
|
instance_addr: 127.0.0.1
|
||||||
|
path_prefix: /loki
|
||||||
|
storage:
|
||||||
|
filesystem:
|
||||||
|
chunks_directory: /loki/chunks
|
||||||
|
rules_directory: /loki/rules
|
||||||
|
replication_factor: 1
|
||||||
|
ring:
|
||||||
|
kvstore:
|
||||||
|
store: inmemory
|
||||||
|
|
||||||
|
schema_config:
|
||||||
|
configs:
|
||||||
|
- from: 2024-01-01
|
||||||
|
store: tsdb
|
||||||
|
object_store: filesystem
|
||||||
|
schema: v13
|
||||||
|
index:
|
||||||
|
prefix: index_
|
||||||
|
period: 24h
|
||||||
|
|
||||||
|
limits_config:
|
||||||
|
retention_period: 168h
|
||||||
|
reject_old_samples: true
|
||||||
|
reject_old_samples_max_age: 168h
|
||||||
|
|
||||||
|
compactor:
|
||||||
|
working_directory: /loki/compactor
|
||||||
|
retention_enabled: true
|
||||||
|
delete_request_store: filesystem
|
||||||
|
|
||||||
|
query_range:
|
||||||
|
results_cache:
|
||||||
|
cache:
|
||||||
|
embedded_cache:
|
||||||
|
enabled: true
|
||||||
|
max_size_mb: 64
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Prometheus scrape config for the dev observability stack. Retention is
|
||||||
|
# a CLI flag in the compose command, not here. Targets are reached by
|
||||||
|
# Docker DNS (compose service names) on galaxy-dev-internal; nothing is
|
||||||
|
# published to the host.
|
||||||
|
global:
|
||||||
|
scrape_interval: 30s
|
||||||
|
evaluation_interval: 30s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: backend
|
||||||
|
static_configs:
|
||||||
|
- targets: ["galaxy-backend:9100"]
|
||||||
|
- job_name: gateway
|
||||||
|
static_configs:
|
||||||
|
- targets: ["galaxy-api:9191"]
|
||||||
|
- job_name: node
|
||||||
|
static_configs:
|
||||||
|
- targets: ["galaxy-node-exporter:9100"]
|
||||||
|
- job_name: cadvisor
|
||||||
|
static_configs:
|
||||||
|
- targets: ["galaxy-cadvisor:8080"]
|
||||||
|
- job_name: prometheus
|
||||||
|
static_configs:
|
||||||
|
- targets: ["localhost:9090"]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Promtail tails the dev stack's container logs via the Docker API
|
||||||
|
# (service discovery filtered to the galaxy.stack=dev-deploy label) and
|
||||||
|
# ships them to Loki. Requires the Docker socket mounted read-only.
|
||||||
|
server:
|
||||||
|
http_listen_port: 9080
|
||||||
|
grpc_listen_port: 0
|
||||||
|
log_level: warn
|
||||||
|
|
||||||
|
positions:
|
||||||
|
filename: /tmp/positions.yaml
|
||||||
|
|
||||||
|
clients:
|
||||||
|
- url: http://galaxy-loki:3100/loki/api/v1/push
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: docker
|
||||||
|
docker_sd_configs:
|
||||||
|
- host: unix:///var/run/docker.sock
|
||||||
|
refresh_interval: 15s
|
||||||
|
filters:
|
||||||
|
- name: label
|
||||||
|
values: ["galaxy.stack=dev-deploy"]
|
||||||
|
relabel_configs:
|
||||||
|
- source_labels: ["__meta_docker_container_name"]
|
||||||
|
regex: "/?(.*)"
|
||||||
|
target_label: container
|
||||||
|
- source_labels: ["__meta_docker_container_label_galaxy_game_id"]
|
||||||
|
target_label: game_id
|
||||||
|
- source_labels: ["__meta_docker_container_log_stream"]
|
||||||
|
target_label: stream
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Single-binary Tempo for the dev stack: OTLP receivers, local block
|
||||||
|
# storage, 3-day retention. Internal-only (no host port). Backend and
|
||||||
|
# gateway push traces here over OTLP gRPC (4317).
|
||||||
|
server:
|
||||||
|
http_listen_port: 3200
|
||||||
|
log_level: warn
|
||||||
|
|
||||||
|
distributor:
|
||||||
|
receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
grpc:
|
||||||
|
endpoint: 0.0.0.0:4317
|
||||||
|
http:
|
||||||
|
endpoint: 0.0.0.0:4318
|
||||||
|
|
||||||
|
ingester:
|
||||||
|
max_block_duration: 5m
|
||||||
|
|
||||||
|
compactor:
|
||||||
|
compaction:
|
||||||
|
block_retention: 72h
|
||||||
|
|
||||||
|
storage:
|
||||||
|
trace:
|
||||||
|
backend: local
|
||||||
|
local:
|
||||||
|
path: /var/tempo/blocks
|
||||||
|
wal:
|
||||||
|
path: /var/tempo/wal
|
||||||
@@ -22,7 +22,7 @@ help:
|
|||||||
@echo " make up Build (if needed) and bring up the stack, wait until healthy"
|
@echo " make up Build (if needed) and bring up the stack, wait until healthy"
|
||||||
@echo " make down Stop compose containers, leave engines + volumes intact"
|
@echo " make down Stop compose containers, leave engines + volumes intact"
|
||||||
@echo " make rebuild Force rebuild of backend / gateway images and bring up"
|
@echo " make rebuild Force rebuild of backend / gateway images and bring up"
|
||||||
@echo " make build-engine Build the engine image $(ENGINE_IMAGE) used by the dev sandbox"
|
@echo " make build-engine Build the engine image $(ENGINE_IMAGE) used by running games"
|
||||||
@echo " make stop-engines Stop and remove only the per-game engine containers"
|
@echo " make stop-engines Stop and remove only the per-game engine containers"
|
||||||
@echo " make prune-broken-engines Remove non-running engine containers Docker can't heal (run inside 'up')"
|
@echo " make prune-broken-engines Remove non-running engine containers Docker can't heal (run inside 'up')"
|
||||||
@echo " make clean Stop everything (incl. engines) and wipe volumes + game state"
|
@echo " make clean Stop everything (incl. engines) and wipe volumes + game state"
|
||||||
@@ -37,8 +37,9 @@ help:
|
|||||||
@echo " pnpm -C ui/frontend dev"
|
@echo " pnpm -C ui/frontend dev"
|
||||||
@echo "and open http://localhost:5173 (UI) plus http://localhost:8025 (Mailpit)."
|
@echo "and open http://localhost:5173 (UI) plus http://localhost:8025 (Mailpit)."
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Default login for the auto-provisioned dev sandbox: dev@local.test"
|
@echo "Sign in with email-OTP; the fixed login code 123456 works when"
|
||||||
@echo "(see BACKEND_DEV_SANDBOX_EMAIL in .env). Login code: 123456."
|
@echo "BACKEND_AUTH_DEV_FIXED_CODE is set in .env. No game is auto-provisioned —"
|
||||||
|
@echo "load a legacy report via the UI's DEV report loader to exercise the map."
|
||||||
|
|
||||||
up: build-engine prune-broken-engines
|
up: build-engine prune-broken-engines
|
||||||
$(COMPOSE) up -d --wait
|
$(COMPOSE) up -d --wait
|
||||||
@@ -88,12 +89,9 @@ stop-engines:
|
|||||||
# bind-mount source and leaves it stuck in `exited` / `created`
|
# bind-mount source and leaves it stuck in `exited` / `created`
|
||||||
# state. This target prunes the husks before `compose up`; the
|
# state. This target prunes the husks before `compose up`; the
|
||||||
# backend's pre-bootstrap reconciler tick (`backend/cmd/backend/main.go`)
|
# backend's pre-bootstrap reconciler tick (`backend/cmd/backend/main.go`)
|
||||||
# then cascades the orphan runtime row to `removed`, the lobby
|
# then cascades the orphan runtime row to `removed` and the lobby
|
||||||
# cancels the game, and the dev-sandbox bootstrap purges the
|
# cancels the game. Healthy `running` / `restarting` containers are
|
||||||
# cancelled tile and provisions a fresh sandbox in the same
|
# left intact so a long-lived game survives normal up/down cycles.
|
||||||
# `make up` cycle. Healthy `running` / `restarting` containers are
|
|
||||||
# left intact so a long-lived sandbox survives normal up/down
|
|
||||||
# cycles.
|
|
||||||
prune-broken-engines:
|
prune-broken-engines:
|
||||||
@ids=""; \
|
@ids=""; \
|
||||||
for cid in $$(docker ps -aq \
|
for cid in $$(docker ps -aq \
|
||||||
|
|||||||
+16
-50
@@ -78,49 +78,24 @@ To force the second path (no fast-bypass), edit
|
|||||||
`make rebuild` (or simply `docker compose up -d backend` to recreate
|
`make rebuild` (or simply `docker compose up -d backend` to recreate
|
||||||
the backend with the new env).
|
the backend with the new env).
|
||||||
|
|
||||||
## Auto-provisioned dev sandbox
|
## No auto-provisioned game
|
||||||
|
|
||||||
`make up` provisions a private game called **Dev Sandbox** owned by
|
`make up` brings up the stack with an empty lobby — there is no
|
||||||
the dev user (default `dev@local.test`). The flow is implemented in
|
auto-provisioned game. Sign in with email-OTP (the fixed dev code
|
||||||
`backend/internal/devsandbox` and runs on every backend boot when
|
`123456` works when `BACKEND_AUTH_DEV_FIXED_CODE` is set in
|
||||||
`BACKEND_DEV_SANDBOX_EMAIL` is non-empty in `tools/local-dev/.env`.
|
`tools/local-dev/.env`):
|
||||||
|
|
||||||
Bootstrap is idempotent — re-running `make up` after a `make down`
|
|
||||||
finds the existing user, dummy participants, game, and memberships
|
|
||||||
without creating duplicates. If a previous boot crashed mid-way
|
|
||||||
(game stuck in `enrollment_open` or `ready_to_start`), the next boot
|
|
||||||
resumes the lifecycle.
|
|
||||||
|
|
||||||
To log in straight into the sandbox:
|
|
||||||
|
|
||||||
1. `make -C tools/local-dev up`
|
1. `make -C tools/local-dev up`
|
||||||
2. `pnpm -C ui/frontend dev` (in another terminal)
|
2. `pnpm -C ui/frontend dev` (in another terminal)
|
||||||
3. Open <http://localhost:5173/login>, enter `dev@local.test`, then
|
3. Open <http://localhost:5173/login>, enter your email, then the dev
|
||||||
the dev code `123456`.
|
code `123456`.
|
||||||
4. The lobby shows **Dev Sandbox** in *My Games*; click in.
|
|
||||||
|
|
||||||
To disable the bootstrap, clear `BACKEND_DEV_SANDBOX_EMAIL` in
|
To exercise the map and report views without running a full game, use
|
||||||
`tools/local-dev/.env` and `docker compose up -d backend` (or
|
the UI's DEV **synthetic report loader**: convert a legacy `.REP` with
|
||||||
`make rebuild`). Existing users / games are not removed.
|
`tools/local-dev/legacy-report/` and load the resulting JSON through the
|
||||||
|
loader (see that tool's README). To play a real game, create one in the
|
||||||
Terminal sandbox games — anything in `cancelled`, `finished`, or
|
lobby and let the engine (`galaxy-engine:local-dev`, built by
|
||||||
`start_failed` — are deleted on every boot before find-or-create
|
`make build-engine`) run it.
|
||||||
runs. The cascade declared in `00001_init.sql` removes the
|
|
||||||
matching memberships, applications, invites, runtime records,
|
|
||||||
and player mappings in the same write, so the dev user's lobby
|
|
||||||
shows exactly one running tile at all times. Cancelling the
|
|
||||||
sandbox manually and running `docker compose restart backend`
|
|
||||||
(or `make rebuild`) yields a fresh game without leaving dead
|
|
||||||
tiles behind.
|
|
||||||
|
|
||||||
The bootstrap requires:
|
|
||||||
- `galaxy-engine:local-dev` Docker image (`make build-engine`).
|
|
||||||
- `BACKEND_DEV_SANDBOX_ENGINE_VERSION` parses as plain semver
|
|
||||||
(`MAJOR.MINOR.PATCH`); the default `0.1.0` is what the bootstrap
|
|
||||||
registers in the `engine_versions` row that points at the image.
|
|
||||||
- `BACKEND_DEV_SANDBOX_PLAYER_COUNT` ≥ 20 (the engine's minimum;
|
|
||||||
19 deterministic dummies fill the slots so the single real user
|
|
||||||
can start the game).
|
|
||||||
- A frozen turn schedule (`0 0 1 1 *` — once a year) so the visible
|
- A frozen turn schedule (`0 0 1 1 *` — once a year) so the visible
|
||||||
game state stays at turn 1 until you explicitly progress it.
|
game state stays at turn 1 until you explicitly progress it.
|
||||||
|
|
||||||
@@ -239,24 +214,15 @@ make status docker compose ps
|
|||||||
this in one cycle: `prune-broken-engines` (runs as part of `up`)
|
this in one cycle: `prune-broken-engines` (runs as part of `up`)
|
||||||
removes every engine container that is not in `running` /
|
removes every engine container that is not in `running` /
|
||||||
`restarting` state, the backend's pre-bootstrap reconciler tick
|
`restarting` state, the backend's pre-bootstrap reconciler tick
|
||||||
cascades the orphan runtime row to `removed`, the lobby cancels
|
cascades the orphan runtime row to `removed`, and the lobby cancels
|
||||||
the matching sandbox game, and the dev-sandbox bootstrap purges
|
the matching game. To run the cleanup by hand without restarting the
|
||||||
the cancelled tile and provisions a fresh sandbox with a brand
|
rest of the stack, `make prune-broken-engines`.
|
||||||
new state directory. To run the cleanup by hand without restarting
|
|
||||||
the rest of the stack, `make prune-broken-engines`.
|
|
||||||
|
|
||||||
The cycle relies on the backend image carrying the pre-bootstrap
|
The cycle relies on the backend image carrying the pre-bootstrap
|
||||||
reconciler tick (`backend/cmd/backend/main.go`). `make up` reuses
|
reconciler tick (`backend/cmd/backend/main.go`). `make up` reuses
|
||||||
the cached image, so after pulling this commit the first time you
|
the cached image, so after pulling this commit the first time you
|
||||||
must `make rebuild` once to bake the fix in. Future `make up`
|
must `make rebuild` once to bake the fix in. Future `make up`
|
||||||
cycles will heal in one shot.
|
cycles will heal in one shot.
|
||||||
|
|
||||||
If after the heal cycle the lobby still shows only a `cancelled`
|
|
||||||
sandbox tile and no running game, the running backend image
|
|
||||||
predates the pre-bootstrap reconciler tick — the periodic ticker
|
|
||||||
cancels the orphan after bootstrap has already returned, leaving
|
|
||||||
the lobby in the half-baked state. `make rebuild` recreates the
|
|
||||||
image and then `make up` lands a fresh sandbox.
|
|
||||||
- **`make up` reports a build error mentioning `pkg/cronutil`** —
|
- **`make up` reports a build error mentioning `pkg/cronutil`** —
|
||||||
upstream module list drifted; copy any new `pkg/<name>/` line into
|
upstream module list drifted; copy any new `pkg/<name>/` line into
|
||||||
the local-dev `backend.Dockerfile` / `gateway.Dockerfile` to match
|
the local-dev `backend.Dockerfile` / `gateway.Dockerfile` to match
|
||||||
|
|||||||
@@ -122,10 +122,6 @@ services:
|
|||||||
BACKEND_OTEL_TRACES_EXPORTER: none
|
BACKEND_OTEL_TRACES_EXPORTER: none
|
||||||
BACKEND_OTEL_METRICS_EXPORTER: none
|
BACKEND_OTEL_METRICS_EXPORTER: none
|
||||||
BACKEND_AUTH_DEV_FIXED_CODE: ${BACKEND_AUTH_DEV_FIXED_CODE:-}
|
BACKEND_AUTH_DEV_FIXED_CODE: ${BACKEND_AUTH_DEV_FIXED_CODE:-}
|
||||||
BACKEND_DEV_SANDBOX_EMAIL: ${BACKEND_DEV_SANDBOX_EMAIL:-}
|
|
||||||
BACKEND_DEV_SANDBOX_ENGINE_IMAGE: ${BACKEND_DEV_SANDBOX_ENGINE_IMAGE:-}
|
|
||||||
BACKEND_DEV_SANDBOX_ENGINE_VERSION: ${BACKEND_DEV_SANDBOX_ENGINE_VERSION:-}
|
|
||||||
BACKEND_DEV_SANDBOX_PLAYER_COUNT: ${BACKEND_DEV_SANDBOX_PLAYER_COUNT:-}
|
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
# Per-game state directories live under the same absolute path
|
# Per-game state directories live under the same absolute path
|
||||||
|
|||||||
@@ -85,16 +85,16 @@ report to fetch. Two alternatives were rejected:
|
|||||||
- a brand-new `user.games.state` message — adds a full wire-flow
|
- a brand-new `user.games.state` message — adds a full wire-flow
|
||||||
(fbs schema, transcoder, gateway routing, backend handler) for a
|
(fbs schema, transcoder, gateway routing, backend handler) for a
|
||||||
one-field response;
|
one-field response;
|
||||||
- hard-coding `turn=0` for all games — works for the dev sandbox
|
- hard-coding `turn=0` for all games — works for a synthetic report
|
||||||
(which never advances past turn zero) but renders the initial
|
loaded at turn zero but mis-renders the initial state for any real
|
||||||
state for any real game past turn zero.
|
game past turn zero.
|
||||||
|
|
||||||
Extending `GameSummary` reuses the existing lobby pipeline; the
|
Extending `GameSummary` reuses the existing lobby pipeline; the
|
||||||
backend already tracks `current_turn` in its runtime projection
|
backend already tracks `current_turn` in its runtime projection
|
||||||
(`backend/internal/server/handlers_user_lobby_helpers.go`
|
(`backend/internal/server/handlers_user_lobby_helpers.go`
|
||||||
`gameSummaryToWire` reads it from `g.RuntimeSnapshot.CurrentTurn`).
|
`gameSummaryToWire` reads it from `g.RuntimeSnapshot.CurrentTurn`).
|
||||||
The `current_turn` field defaults to zero on the FB side, so existing
|
The `current_turn` field defaults to zero on the FB side, so existing
|
||||||
tests and the dev sandbox flow continue to work unchanged.
|
tests and the synthetic-report flow continue to work unchanged.
|
||||||
|
|
||||||
## State binding
|
## State binding
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// DEV-only synthetic-report loader. Backs the "Load synthetic report"
|
// DEV-only synthetic-report loader. Backs the "Load synthetic report"
|
||||||
// affordance on the lobby (visible behind `import.meta.env.DEV`) and
|
// affordance on the lobby (visible when the build-time flag
|
||||||
// the in-game shell layout's bypass for the synthetic game id range.
|
// `VITE_GALAXY_DEV_AFFORDANCES === "true"` — the dev and dev-deploy
|
||||||
|
// bundles; stripped from prod) and the in-game shell layout's bypass
|
||||||
|
// for the synthetic game id range.
|
||||||
//
|
//
|
||||||
// The accepted JSON shape mirrors `pkg/model/report.Report` as
|
// The accepted JSON shape mirrors `pkg/model/report.Report` as
|
||||||
// emitted by `tools/local-dev/legacy-report/cmd/legacy-report-to-json`.
|
// emitted by `tools/local-dev/legacy-report/cmd/legacy-report-to-json`.
|
||||||
|
|||||||
Reference in New Issue
Block a user