Adds docker-compose.arena.yml (registry image only, no build, no payment env, no TRUSTED_PROXY since no reverse proxy fronts it) and docs/arena-deployment.md (full runbook: host paths, port, secret handling, seed decision, NPM/TLS upgrade path, teardown) for the one canonical public arena at http://146.59.87.168:9100 (user decision 2026-07-30: no DNS/TLS this phase, plain HTTP on the raw port; TLS is a later env-only upgrade). Deployed + verified on VPS2: healthy on-host and off-host, seeded with a full read-only copy of archi-dev-box's botfights.db (115 bots/102,440 fights; source untouched, mtime verified unchanged). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||||
@@ -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://<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** | **`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).
|
||||||
Reference in New Issue
Block a user