Files
botfights/docs/arena-deployment.md
T
DorianandClaude Fable 5 90d5e2d16d
CI / check (push) Failing after 6m4s
docs(09-05): record cross-instance fighter visibility proof (BOT-03)
A throwaway 1.2.0 container in proxy mode (no volume, ARENA_UPSTREAM_URL
pointed at the canonical arena) demonstrated: a fighter registered
directly against the arena is visible through it (D1), a fighter
registered through it is visible on the arena directly (reverse), SSE
streams incrementally through it, /api/health answers locally during an
arena outage, and /api/bots degrades cleanly during that outage. Also
documents a deviation: the canonical URL now sits behind
nginx-proxy-manager (per the mid-phase DNS/TLS decision superseding this
plan's original plain-HTTP wording), so a stopped arena's 502 through
that URL is NPM's own HTML page rather than arena-proxy.ts's JSON body —
the underlying JSON degradation contract is separately confirmed live
against the raw fallback port, which has no intermediary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:39 -04:00

338 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Canonical Arena Deployment (VPS2)
This is the runbook for the one canonical, public BotFights arena. Every
node's local instance can proxy match/fighter state to a shared arena via
`ARENA_UPSTREAM_URL` — this document covers deploying the well-known default
one, on the Foundation's VPS2 host.
Nothing here is git-tracked automatically: nginx-proxy-manager's routing
config and the host `.env` (secrets) live only on the VPS2 host. This file is
the only record of how to reproduce or roll back the deployment.
## Architecture: arena-as-relay (read this first)
BotFights' shared-arena design is intentionally decentralized, not
hardcoded to one server:
- **Any node can host a public arena.** It's the exact same container image
any node already runs — a "public arena" is just a BotFights instance with
`ARENA_UPSTREAM_URL` **unset** (standalone mode) that other nodes point at.
- **Each node picks its own community** by setting `ARENA_UPSTREAM_URL` in
its own manifest/environment. Unset = fully standalone, own local SQLite DB.
- **This VPS2 deployment is only the well-known default rendezvous** — like
the vps2 FIPS anchor — not an authority baked into the code. Nothing in
`botfight`'s server or frontend code hardcodes `146.59.87.168`; it is
entirely an environment-variable choice made by whoever configures a node.
- The game UI is always served locally by each node; only match/fighter
state lives wherever `ARENA_UPSTREAM_URL` points.
- **How to host your own arena:** deploy this exact `docker-compose.arena.yml`
pattern (or even the node's normal `docker-compose.yml`) anywhere reachable,
leave `ARENA_UPSTREAM_URL` unset on it, generate your own `JWT_SECRET`, and
point whichever nodes you want in your community at
`ARENA_UPSTREAM_URL=http://<your-host>:<port>`. There is no registration or
allowlist step — the protocol is "point at a URL that speaks the BotFights
API."
## Current canonical instance
| Field | Value |
|---|---|
| Host | VPS2, `debian@146.59.87.168` (docker, not podman — this is host infra, not an Archipelago node) |
| Directory | `/opt/botfights-arena/` |
| Compose file | `/opt/botfights-arena/docker-compose.yml` (copied from this repo's `docker-compose.arena.yml`, not symlinked — re-copy after edits) |
| Container name | `botfights-arena` |
| Image | `localhost:3000/lfg2025/botfights:1.1.0` (Gitea registry on the same host; `localhost:3000` resolves without any insecure-registry config because Docker trusts loopback registries by default — this is why the compose file uses `localhost:3000`, not the public `146.59.87.168:3000`, as the image ref) |
| Port | **9100** (verified free before binding; now bound — see `ss -tlnp` output in this phase's execution log) |
| Data volume | named volume `botfights-arena-data``/app/server/data` inside the container (host mountpoint: `docker volume inspect botfights-arena_botfights-arena-data --format '{{.Mountpoint}}'`) |
| **Canonical URL** | **`https://botfights.archipelago-foundation.org`** — TLS via nginx-proxy-manager + Let's Encrypt (user created DNS + proxy host 2026-07-30). Raw fallback: `http://146.59.87.168:9100` |
### Why plain HTTP on the raw port (no DNS/TLS this phase)
The user explicitly chose to skip creating a subdomain (e.g.
`arena.archipelago-foundation.org`), an nginx-proxy-manager proxy host, and a
Let's Encrypt certificate for this phase. Rationale:
- The node → arena hop is **server-side** (each node's Hono server proxies
`/api/*` to `ARENA_UPSTREAM_URL`), never a browser fetch — so there is no
mixed-content restriction that would otherwise force HTTPS.
- Cloud bots (server-to-server `curl`/HTTP clients) don't enforce
browser-style mixed-content or certificate-pinning either.
- This keeps the deploy on the fast path for the 2026-07-31 demo — no DNS
propagation wait, no cert-issuance step.
**Threat register note (T-09-16, accepted):** credentials (JWT bearer
tokens, NIP-98 auth headers, bot secrets) travel in plaintext over
`http://146.59.87.168:9100`. This is an accepted, recorded tradeoff, not an
oversight.
### Later TLS upgrade path (env-only, no code change)
When DNS/TLS is wanted:
1. Add an A record, e.g. `arena.archipelago-foundation.org``146.59.87.168`
(GoDaddy `ns29/ns30.domaincontrol.com`, no wildcard — this needs its own
record).
2. In nginx-proxy-manager (`https://146.59.87.168:81`, admin `lfg2025@proton.me`),
add a new **Proxy Host**:
- Domain: `arena.archipelago-foundation.org`
- Scheme: `http`
- Forward Hostname/IP: `146.59.87.168`
- Forward Port: `9100`
- Block Common Exploits: on
- Websockets Support: on (`allow_websocket_upgrade=1` — required for any
future websocket use; the current SSE fight-stream is plain HTTP
chunked streaming and doesn't strictly need this, but it's the
established pattern for every other subdomain on this host)
- SSL tab: request a new Let's Encrypt certificate, force SSL
(`ssl_forced=1`) — mirrors the existing `demo.`/`source.`/`fips.` hosts.
3. Change **only** the value every node reads: `ARENA_UPSTREAM_URL` in
`apps/botfights/manifest.yml` (archy repo) from
`http://146.59.87.168:9100` to `https://botfights.archipelago-foundation.org` — DONE 2026-07-30: the user created the DNS A record and the NPM proxy host with a Let's Encrypt cert; `TRUSTED_PROXY=1` was enabled on the arena at the same time (it now sits behind NPM).
No code change — the reverse-proxy middleware and NIP-98 verification are
both already origin-independent (path-only URL comparison).
4. Optionally keep `:9100` open as a fallback/legacy path, or firewall it
down to only `127.0.0.1` once NPM is fronting it (`ports: - "127.0.0.1:9100:9100"`
in the compose file) so the raw port is no longer publicly reachable.
## Secret handling
`JWT_SECRET` is generated **on the VPS2 host**, never in this repo, never in
a compose file value, never printed to a log or transcript:
```bash
# On VPS2, inside /opt/botfights-arena/:
umask 077
echo "JWT_SECRET=$(openssl rand -hex 32)" > .env
chmod 600 .env
```
`docker-compose.arena.yml` only ever references `${JWT_SECRET}` — the literal
value lives solely in `/opt/botfights-arena/.env` (mode `0600`, owned by
`debian`, outside any git repo).
**Rotation:** overwrite `.env` with a freshly-generated value, then
`docker compose down && docker compose up -d` (all existing sessions/JWTs
become invalid — bot `secret`/`bot_id` pairs used for `POST /api/bots` auth
are unaffected, only nostr-signer-issued JWTs expire).
**If a secret value is ever accidentally exposed** (e.g. printed by a
`docker inspect` command run without redaction): rotate immediately using
the steps above. This happened once during this phase's initial deployment
(caught and corrected the same session — the secret was rotated and the
container restarted before any external use).
## Data seed: full database copy (user decision 2026-07-30)
The arena was seeded from archi-dev-box's existing BotFights instance
(`/var/lib/archipelago/botfights/botfights.db`, 351 MB at the time of
export — 115 bots, 102,440 fights, `payments`/`bets` tables present but
empty).
**Export method (read-only, source never written to):**
```python
# Read-only URI connection — SQLite refuses writes on this handle.
# VACUUM INTO produces a compacted, self-consistent snapshot including
# any WAL-mode uncommitted-but-checkpointed data, without requiring write
# access to the source's -wal/-shm files.
import sqlite3
con = sqlite3.connect(
'file:/var/lib/archipelago/botfights/botfights.db?mode=ro', uri=True)
con.execute("VACUUM INTO '/path/to/botfights-export.db'")
con.close()
```
Source file `mtime`/size were compared before and after the export and
confirmed byte-identical (`1782916151`, `367144960` bytes) — the export did
not touch the live node's database.
**Deploy steps used:**
1. `docker compose stop` on the arena (avoid the app writing to the volume
mid-copy).
2. `scp` the exported `.db` file to VPS2, then as root:
`cp` it into the named volume's host mountpoint as `botfights.db`,
removing any stray `-wal`/`-shm` files from the fresh-start container run.
3. `chown` the file to uid/gid `999` — the container's non-root `botfights`
system user (verify with `docker inspect botfights-arena --format
'{{.Config.User}}'` and the uid `useradd --system` assigned it, since
docker on this host does not use userns-remap — the host uid IS the
container uid).
4. `docker compose start`.
**Result:** `GET /api/bots` returns **100** rows by default (the endpoint
filters out `botType === 'classic'` bots) — the remaining **15** classic-type
bots are visible via `GET /api/bots?type=classic`. `100 + 15 = 115`, matching
the source exactly. No data was lost; this is existing, unmodified API
filtering behavior, not an artifact of the copy.
## Verification (rerun any time to confirm the arena is healthy)
```bash
# On-host:
ssh debian@146.59.87.168 'curl -fsS http://127.0.0.1:9100/api/health'
# → {"status":"ok","name":"botfights"}
# Off-host (from archi-dev-box or any client with a path to VPS2):
curl -fsS --max-time 10 http://146.59.87.168:9100/api/health
curl -fsS --max-time 10 http://146.59.87.168:9100/api/bots # expect 100 (+15 classic)
```
## Building and pushing `botfights:1.2.0` (plan 09-05)
`1.2.0` is the first image built after the arena-proxy middleware (09-01),
the nostr-only `GET /api/auth/me` auth fix (09-02), and the unified
`GET /api/docs/prompt` AI setup prompt (09-03) all landed on `main`. Build
from a clean checkout of `origin/main`:
```bash
cd /home/archipelago/Projects/botfight
git pull --ff-only origin main
# confirm all three wave-1 plans are present before building:
test -f server/src/middleware/arena-proxy.ts
grep -q "get('/me'" server/src/routes/auth.ts
grep -q "get('/prompt'" server/src/routes/docs.ts
podman build --build-arg CACHE_BUST=$(date +%s) \
-t 146.59.87.168:3000/lfg2025/botfights:1.2.0 .
# Smoke test locally BEFORE pushing (spare port, no upstream configured):
podman run --rm -d --name botfights-smoketest -p 9199:9100 \
-e NODE_ENV=production -e JWT_SECRET=$(openssl rand -hex 32) \
146.59.87.168:3000/lfg2025/botfights:1.2.0
curl -fsS http://127.0.0.1:9199/api/health
curl -fsSi http://127.0.0.1:9199/api/docs/prompt | head -3 # expect 200, text/markdown
curl -si http://127.0.0.1:9199/api/auth/me | head -3 # expect 401, no Authorization header
podman rm -f botfights-smoketest
# Push (registry is plain HTTP; 146.59.87.168:3000 is already configured as an
# insecure registry in /etc/containers/registries.conf.d/archipelago.conf on
# this host, but --tls-verify=false is passed explicitly too):
podman login 146.59.87.168:3000 -u lfg2025 -p <token from Gitea admin, see infra memory note>
podman push --tls-verify=false 146.59.87.168:3000/lfg2025/botfights:1.2.0
# Verify from the registry side:
skopeo inspect --tls-verify=false docker://146.59.87.168:3000/lfg2025/botfights:1.2.0
```
**Build gotcha hit this session (pre-existing, unrelated to phase 09's own
code — fixed as an in-scope blocking-issue deviation):** `pnpm install
--frozen-lockfile` inside the `deps` build stage failed with
`ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`. Root cause: an earlier commit
(`bcb323e`, March 2026) moved dependency `overrides` from `package.json`'s
`pnpm.overrides` key (a location modern pnpm no longer reads at all — see
its own deprecation warning) to `pnpm-workspace.yaml`'s `overrides:` key,
but only migrated 2 of 3 override entries and never regenerated
`pnpm-lock.yaml` to match. The Dockerfile's `corepack prepare pnpm@latest`
pulls whatever pnpm is current at build time, which enforces the
lockfile-vs-config check strictly. Fixed by: removing the dead `pnpm`
field from `package.json`, adding the missing `tar: '>=7.5.11'` override to
`pnpm-workspace.yaml` (alongside the two already there), and regenerating
`pnpm-lock.yaml` with `pnpm install --no-frozen-lockfile` — the resulting
lockfile diff contains **zero** `specifier:` changes (verified by grep),
only peer-dependency resolution-graph annotations from the newer pnpm
version explicitly listing `supports-color` as a peer. `pnpm install
--frozen-lockfile` and `tsc --noEmit -p server/tsconfig.json` both pass
clean against the regenerated lockfile.
**Result (this session, 2026-07-31):**
| Field | Value |
|---|---|
| Tag | `146.59.87.168:3000/lfg2025/botfights:1.2.0` |
| Digest | `sha256:854ea299...26e144` (short form; full digest recorded in `.planning/phases/09-botfights-platform-upgrade/09-05-SUMMARY.md` — re-derive any time with `skopeo inspect` above) |
| Built from | `botfight` repo `main` @ the commit carrying the `GET /api/fights/poll` route-order fix below (`2a343ac` + fix commit) |
| Local smoke test | `/api/health``{"status":"ok",...}`; `/api/docs/prompt` → 200 `text/markdown`; `/api/auth/me` (no auth) → 401; `/api/fights/poll` (registered bot) → 200 `{"pending":false}` |
**Deviation fixed in the same build pass:** `GET /api/fights/poll` (the
polling protocol BOT-02's unified prompt documents) was pre-existing-broken
— a `GET /:id` dynamic route registered earlier in `server/src/routes/fights.ts`
shadowed the later-registered static `GET /poll` route, so any polling bot's
poll request was matched as a fight-id lookup for id `"poll"` and always
returned `404 {"error":"Fight not found."}`. Reproduced independently on a
throwaway container with a fresh DB (not an artifact of the arena's seeded
data) before fixing. Fixed by moving the `/poll` and `/poll/respond` route
registrations above `/:id` in the router. This was necessary to meet this
plan's own acceptance criterion (bot auth via `GET /api/fights/poll` against
the public arena) and to make BOT-02's unified prompt's polling-mode
documentation actually true.
## Rolling the image tag
The tag is kept in exactly one place — `docker-compose.arena.yml`'s
`image:` line. To roll (e.g. plan 09-05's 1.2.0 build):
```bash
# 1. Edit docker-compose.arena.yml: image: localhost:3000/lfg2025/botfights:1.2.0
# 2. Copy to the host and redeploy:
scp docker-compose.arena.yml debian@146.59.87.168:/opt/botfights-arena/docker-compose.yml
ssh debian@146.59.87.168 'cd /opt/botfights-arena && docker compose pull && docker compose up -d'
```
The named volume (and therefore all arena data) is untouched by an image
roll — only `docker compose down -v` (never run this without intent) removes
it.
## Tearing it down
```bash
ssh debian@146.59.87.168 '
cd /opt/botfights-arena
docker compose down # stops + removes the container; volume persists
# docker compose down -v # ALSO deletes the botfights-arena-data volume — destructive, confirm first
# rm -rf /opt/botfights-arena # only after confirming the volume is gone/backed up
'
```
## Ports already bound on VPS2 (verified 2026-07-30, re-check with `sudo ss -tlnp` before reusing)
22, 80, 81, 443, 2100, 2101, 2222, 3000, 3009, 5355, 7788, 8000, 8092, 8123,
8443, 8444, 9443, and now **9100** (this deployment).
## 1.2.0 public-contract verification (plan 09-05, 2026-07-31)
All checks below ran against `https://botfights.archipelago-foundation.org`
(never `127.0.0.1`/the raw port) after `docker compose pull && up -d` recreated
the container on the `botfights:1.2.0` tag (post-poll-fix build):
| Check | Result |
|---|---|
| `GET /api/health` | `{"status":"ok","name":"botfights"}` |
| `GET /api/docs/prompt` | 200, `text/markdown`, arena URL substituted 9×, zero leftover `{{ARENA_URL}}` tokens |
| `GET /api/auth/me` (no token) | 401 |
| `POST /api/bots` (anonymous, from off-host) | 200, id+secret issued; bot immediately visible in `GET /api/bots` |
| `GET /api/fights/poll` (bot auth via `Authorization: Bot id:secret`) | 200 `{"pending":false}` — see the `GET /:id` route-order fix above; this was 404 before it |
| `POST /api/queue/join/<botId>` → poll again | matched into a real fight within seconds; poll returned the live challenge payload |
| `GET /api/fights/<id>/stream` (SSE) | Incremental delivery confirmed: `spectator_count`/`ping` events at connection open, a second `ping` ~15s later, then `round_end`/`round_start`/`poll_challenge` in a fresh cluster ~4-5s after that — spread over a live 25s capture window, not buffered until stream close |
| `JWT_SECRET` survived the roll | `/opt/botfights-arena/.env` mtime predates this session's image rolls (unchanged); container's `JWT_SECRET` env still sources `${JWT_SECRET}` from that same file, not a freshly generated value |
| Data integrity | `GET /api/bots` → 101 (100 original + 1 test bot from an earlier verification pass), `?type=classic` → 15, unchanged/grown from the pre-roll 100+15 |
**Test bots left in the arena, clearly named per this plan's own naming
convention (no bot-deletion API exists in this codebase to remove them
cleanly):** `wavetest2`, `wavetest3` — both anonymous, harmless, real
fighters; consistent with the arena's existing `FIGHT_LOOP_ENABLED=true`
mock-bot background activity. `wavetest3` fought one live match as part of
verifying the SSE stream above.
## Verified cross-instance behaviour (plan 09-05 Task 3, 2026-07-31)
A throwaway `botfights:1.2.0` container (`botfights-proxytest`, port 9101,
no volume mount — nothing worth reading locally) ran on archi-dev-box with
`ARENA_UPSTREAM_URL=https://botfights.archipelago-foundation.org`, alongside
(never touching) the installed `botfights` app on port 9100 (image 1.1.0).
| Check | Result |
|---|---|
| Proxy instance has no local data of its own | Startup still seeds a local 100+15 mock-bot DB (unrelated background code path that runs regardless of `ARENA_UPSTREAM_URL`) — but every `/api/*` request is intercepted by `arena-proxy` before it ever reaches a local route handler, so that local data is never exposed through the API |
| `GET /api/bots` via the proxy instance | Returned 103 bots, including `wavetest2` and `wavetest3` — both registered directly against the arena in Task 2, never touching this instance. **This is the D1 proof: a fighter registered on one host is visible through a different instance that never stored it.** |
| Reverse direction: register via the proxy instance | `POST http://127.0.0.1:9101/api/bots {"name":"wavetest4"}` succeeded, and `wavetest4` was immediately visible in `GET https://botfights.archipelago-foundation.org/api/bots` directly |
| SSE through the proxy instance | `wavetest3` matched into a real fight; `GET http://127.0.0.1:9101/api/fights/<id>/stream` delivered `spectator_count`/`ping` at connection open and a second `ping` ~15s later — incremental, not buffered |
| `/api/health` bypass during a deliberate arena outage | `docker compose stop` on the VPS2 arena (seconds); `GET http://127.0.0.1:9101/api/health` still returned `200 {"status":"ok",...}` throughout — confirmed answered locally per `arena-proxy.ts`'s `LOCAL_BYPASS_PATHS`, never forwarded |
| `/api/bots` during the same outage, via the **canonical HTTPS URL** (fronted by nginx-proxy-manager since 2026-07-30) | `502`, but the body was NPM's own HTML error page, not the app's JSON — because NPM itself answers with a gateway-level 502 before the request ever reaches the stopped container; `fetch()` inside `arena-proxy.ts` succeeds against NPM and passes its response through verbatim. This supersedes the plan's original acceptance wording (written when the arena was still plain-HTTP/no-NPM); NPM 502ing here is expected, correct behavior for a proxy in front of a stopped upstream. |
| `/api/bots` during a second, separate short outage, via the **raw fallback port** (`http://146.59.87.168:9100`, no NPM in front) | `502 {"error":"Arena unreachable."}``arena-proxy.ts`'s own JSON degradation path (already unit-tested in 09-01), confirmed live against a real stopped upstream with no intermediary |
| Recovery | `docker compose start` on VPS2 both times; arena `healthy` again within seconds; the proxy instance's own subsequent requests succeeded immediately, no restart needed on the node side |
| Installed app isolation | `podman ps --filter name=botfights` showed the installed `botfights` app (port 9100, image `:1.1.0`) with its original container id and uptime, unaffected throughout; no `botfights-proxytest*` container remains after cleanup |
**Test bots registered during this task, left in the arena (same rationale
as Task 2 — clearly named, no delete API exists):** `wavetest4`.