diff --git a/docker-compose.arena.yml b/docker-compose.arena.yml new file mode 100644 index 0000000..1c4a947 --- /dev/null +++ b/docker-compose.arena.yml @@ -0,0 +1,62 @@ +# docker-compose.arena.yml — the CANONICAL public BotFights arena +# +# This is the counterpart to docker-compose.yml (the local/dev stack). It runs +# ONLY the published registry image (no `build:` section — the arena runs exactly +# what nodes run, never a locally-built variant), with payments deliberately +# unconfigured and no reverse proxy in front (direct exposure on :9100, so the +# app's own rate limiter must see the real socket peer IP — see TRUSTED_PROXY note +# below). +# +# Deploy notes live in docs/arena-deployment.md — this file has no secrets. The +# JWT_SECRET value is generated on the host into /opt/botfights-arena/.env (0600, +# never committed). +# +# Arena-as-relay: this compose file is not special — it is the SAME image any +# node can run standalone (no ARENA_UPSTREAM_URL) to host its own public arena. +# The Foundation's VPS2 instance below is just the well-known default rendezvous, +# not a hardcoded authority. See docs/arena-deployment.md "Hosting your own arena". + +services: + botfights-arena: + image: localhost:3000/lfg2025/botfights:1.1.0 + container_name: botfights-arena + restart: unless-stopped + ports: + - "9100:9100" + volumes: + - botfights-arena-data:/app/server/data + # Explicit override (not just relying on the image's baked-in HEALTHCHECK): + # the currently published 1.1.0 tag predates the Dockerfile's HEALTHCHECK + # directive, so `docker ps` shows no health status without this. + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://localhost:9100/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 + environment: + - NODE_ENV=production + - PORT=9100 + - FIGHT_LOOP_ENABLED=true + - PUBLIC_ARENA_URL=http://146.59.87.168:9100 + # Auth — value comes from the host .env, never hardcoded here. + # Generated on VPS2 with: openssl rand -hex 32 (see docs/arena-deployment.md) + - JWT_SECRET=${JWT_SECRET} + # Deliberately OMITTED: TRUSTED_PROXY + # No NPM/reverse-proxy sits in front of this instance (plain HTTP on the + # raw port, user decision 2026-07-30 — no DNS/TLS this phase). Clients hit + # :9100 directly, so the app's rate-limit middleware must key off the real + # TCP socket peer IP, not a forwarded header a direct caller could forge. + - BOTFIGHTS_CREATOR_PUBKEYS=${BOTFIGHTS_CREATOR_PUBKEYS:-da5e0c1b646bdb13c2300f805b0ca3e5afe5b052c594ce78bac8978d21c3fa39} + # Deliberately OMITTED: this instance IS the upstream — never point it at + # another arena. + # - ARENA_UPSTREAM_URL= + # ── Payments are OUT OF SCOPE for this deployment (phase 09 CONTEXT.md + # scope fence: "OUT: Lightning/cashu payment changes"). Do not set: + # - BOTFIGHTS_WALLET_ENCRYPTION_KEY= + # - BOTFIGHTS_NWC_URL= + # - BOTFIGHTS_CASHU_MINT_URL= + # - BOTFIGHTS_DEV_PAYOUT_LNADDRESS= + +volumes: + botfights-arena-data: diff --git a/docs/arena-deployment.md b/docs/arena-deployment.md new file mode 100644 index 0000000..4af889d --- /dev/null +++ b/docs/arena-deployment.md @@ -0,0 +1,211 @@ +# 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://:`. 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** | **`http://146.59.87.168:9100`** — plain HTTP, no DNS, no TLS (user decision 2026-07-30, see below) | + +### 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://arena.archipelago-foundation.org`. + 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) +``` + +## 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).