diff --git a/.claude/skills/waving-hands-pulse/SKILL.md b/.claude/skills/waving-hands-pulse/SKILL.md index a592bb1..75b95b0 100644 --- a/.claude/skills/waving-hands-pulse/SKILL.md +++ b/.claude/skills/waving-hands-pulse/SKILL.md @@ -7,7 +7,7 @@ description: The weekly Waving Hands operations pulse — duel server health, er One command on the droplet prints everything; read it as a short report. - ssh root@159.203.98.71 waving-hands-pulse.sh + ssh root@159.203.115.252 waving-hands-pulse.sh - **duel server**: whether the service and Caddy are up, restarts and journal errors in the last seven days with the last three error lines. diff --git a/.claude/skills/waving-hands-reports/SKILL.md b/.claude/skills/waving-hands-reports/SKILL.md index 3f43dec..c2466cc 100644 --- a/.claude/skills/waving-hands-reports/SKILL.md +++ b/.claude/skills/waving-hands-reports/SKILL.md @@ -7,7 +7,7 @@ description: Work the Waving Hands reports desk — fetch players' bug reports, Players file reports from the Report button in a room's masthead (seated or from the gallery). Each lands in `/var/lib/waving-hands/feedback.jsonl` -on the droplet (159.203.98.71) pinned with `roomId`, `turn` (the turn +on the droplet (159.203.115.252) pinned with `roomId`, `turn` (the turn being written when it was filed) and `seq` (the ledger's length then), enough to replay the duel to the moment. Replies live in the same file and appear under the report in the player's hall. This desk fetches, @@ -34,7 +34,7 @@ Every report and every player's answer rings a Sentry issue (project waving-hands, fingerprinted per report or per line); the desk's own replies ring nothing. -Fetch: `ssh root@159.203.98.71 'cat /var/lib/waving-hands/feedback.jsonl'` +Fetch: `ssh root@159.203.115.252 'cat /var/lib/waving-hands/feedback.jsonl'` ## a) Fetch and b) display @@ -47,7 +47,7 @@ to hear his players' voices; never compress their words into a table. ## c) Process an unanswered report -1. **Replay to the pin.** `scp root@159.203.98.71:/var/lib/waving-hands/rooms/.jsonl /` +1. **Replay to the pin.** `scp root@159.203.115.252:/var/lib/waving-hands/rooms/.jsonl /` and replay it with the engine as `deploy/replay-ledgers.ts` does: `createGame(names, start.seed)` then `resolveTurn(state, line.inputs)` per turn line, printing `state.log` around the pinned turn. The @@ -70,8 +70,8 @@ to hear his players' voices; never compress their words into a table. passing `rules` to `createGame`). Old ledgers then replay as their players saw them; new duels get the fix. Either way add a test in `src/lib/game/engine.test.ts` pinning the - corrected behaviour. Deploy with `deploy/deploy.sh 159.203.98.71`. -5. **Reply.** `bash deploy/report-reply.sh 159.203.98.71 "text"`. + corrected behaviour. Deploy with `deploy/deploy.sh 159.203.115.252`. +5. **Reply.** `bash deploy/report-reply.sh 159.203.115.252 "text"`. Pass the id as ONE clean argument. Write to the PLAYER: name what you replayed, cite the rule, and say plainly what was wrong or why nothing was. The desk's voice is warm and specific. diff --git a/.claude/skills/waving-hands-visitors/SKILL.md b/.claude/skills/waving-hands-visitors/SKILL.md index f096d4c..0464897 100644 --- a/.claude/skills/waving-hands-visitors/SKILL.md +++ b/.claude/skills/waving-hands-visitors/SKILL.md @@ -10,7 +10,7 @@ host glancing over the hall. ## Gather - ssh root@159.203.98.71 waving-hands-visitors.sh [YYYY-MM-DD] + ssh root@159.203.115.252 waving-hands-visitors.sh [YYYY-MM-DD] Default is today, UTC; Eric is in US Eastern, so an evening at his desk spills into the next UTC day. Run yesterday too when the hour is early. diff --git a/deploy/Caddyfile.tmpl b/deploy/Caddyfile.tmpl index d24d919..fd38a99 100644 --- a/deploy/Caddyfile.tmpl +++ b/deploy/Caddyfile.tmpl @@ -1,42 +1,42 @@ -__HOST__ +__HOST__ { + root * /opt/waving-hands/build + encode gzip zstd -root * /opt/waving-hands/build -encode gzip zstd + header { + Strict-Transport-Security "max-age=31536000" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "no-referrer" + } -header { - Strict-Transport-Security "max-age=31536000" - X-Content-Type-Options "nosniff" - X-Frame-Options "DENY" - Referrer-Policy "no-referrer" -} + log { + output file /var/lib/caddy/waving-hands-access.log { + roll_size 10MiB + roll_keep 30 + } + } -log { - output file /var/lib/caddy/access.log { - roll_size 10MiB - roll_keep 30 + # The game server answers the API and the websocket; a deploy restarts it for + # a few seconds, and the proxy holds requests that land in that gap. + @game path /api/* /ws + handle @game { + reverse_proxy localhost:8788 { + lb_try_duration 30s + lb_try_interval 250ms + } + } + + # Everything else is the static site. SvelteKit writes each route as + # .html, so /rules is tried as /rules.html before falling back to the + # app shell, which serves the rooms. Hashed assets under _app/immutable are + # cached for a year; every other response is revalidated so a deploy shows + # up on the next load. + handle { + @immutable path /_app/immutable/* + header @immutable Cache-Control "public, max-age=31536000, immutable" + @mutable not path /_app/immutable/* + header @mutable Cache-Control "no-cache" + try_files {path} {path}.html /index.html + file_server } } - -# The duel server answers the API and the websocket; a deploy restarts it for -# a few seconds, and the proxy holds requests that land in that gap. -@duel path /api/* /ws -handle @duel { - reverse_proxy localhost:8788 { - lb_try_duration 30s - lb_try_interval 250ms - } -} - -# Everything else is the static site. SvelteKit writes each route as -# .html, so /rules is tried as /rules.html before falling back to the -# app shell, which serves the rooms. Hashed assets under _app/immutable are -# cached for a year; every other response is revalidated so a deploy shows -# up on the next load. The two header matchers are disjoint. -handle { - @immutable path /_app/immutable/* - header @immutable Cache-Control "public, max-age=31536000, immutable" - @mutable not path /_app/immutable/* - header @mutable Cache-Control "no-cache" - try_files {path} {path}.html /index.html - file_server -} diff --git a/deploy/README.md b/deploy/README.md index c967f4d..778d58d 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,51 +1,50 @@ # Deploying Waving Hands -The single-player game runs entirely in the browser. Duels between people -go through a small Node process that keeps each room as an append-only -ledger of moves in /var/lib/waving-hands/rooms. Production is one -DigitalOcean droplet: Caddy terminates TLS with automatic certificates, -serves the static build from /opt/waving-hands/build, and proxies /api and -/ws to the duel server on port 8788, which runs as the `waving-hands` user -under systemd from /opt/waving-hands/app. +Every game is a room on a small Node process that keeps it as an +append-only ledger of moves in /var/lib/waving-hands/rooms (the unit names it with WH_DATA_DIR). Production is the +shared Kestrel's Hall host: Caddy terminates TLS with automatic +certificates, serves the static build from /opt/waving-hands/build, and proxies +/api and /ws to the game server on port 8788, which runs as the +`waving-hands` user under systemd from /opt/waving-hands/app. -## Sizing +## The shared host -The smallest droplet, `s-1vcpu-512mb-10gb` ($4 a month), carries both Caddy -and the duel server comfortably: the server is one small Node process capped -at 300 MB by its unit file, and a room is a few kilobytes of ledger. - -The zero-cost alternative is a second site block in an existing Caddy -server's configuration pointing at a second directory; the deploy script -works unchanged against that host. A droplet of its own keeps this site's -uptime and upgrades independent of anything else. +The games of Kestrel's Hall share one droplet, `hall` (nyc3, s-1vcpu-1gb, +$6 a month, tag `kestrels-hall`, IP 159.203.115.252). Each game keeps its +own Caddy site file in /etc/caddy/sites/.caddy, its own port, +service user, /opt/ and /var/lib/ trees, access log +(/var/lib/caddy/-access.log), cron file and ops scripts; +/etc/caddy/Caddyfile only says `import sites/*.caddy`. A game server is one +small Node process capped at 300 MB by its unit file, and a room is a few +kilobytes of ledger, so the host carries several. Each game needs a port +of its own on the host: wizwar 8787, waving-hands 8788, hnefatafl 8789, +dip 8790. ## Current production -- Droplet: `waving-hands` (nyc3, s-1vcpu-512mb-10gb, tag `waving-hands`), IP 159.203.98.71 -- URLs: https://hands.kestrelsnest.social (A record at Hover, where - kestrelsnest.social's DNS lives) and https://waving-hands.159.203.98.71.sslip.io - (always works, zero DNS). Caddy serves both from the first line of - /etc/caddy/Caddyfile. -- Everyday deploy: `deploy/deploy.sh 159.203.98.71` +- Host: 159.203.115.252 +- URLs: https://hands.kestrelsnest.social (A record at Hover, where kestrelsnest.social's + DNS lives) and https://hands.159.203.115.252.sslip.io (always works, zero DNS). +- Everyday deploy: `SITE=https://hands.159.203.115.252.sslip.io deploy/deploy.sh 159.203.115.252` + (this Mac's resolver caches the old *.kestrelsnest.social wildcard, so the gate reads the sslip name). +- Moved here from its own droplet (159.203.98.71) on 2026-09-25. - Players' reports: `deploy/pull-reports.sh` mirrors /var/lib/waving-hands/feedback.jsonl and the screenshots to ~/Desktop/waving-hands-reports with a digest; - `deploy/report-reply.sh 159.203.98.71 "text"` answers one. + `deploy/report-reply.sh 159.203.115.252 "text"` answers one. -## New droplet from scratch +## Adding this game to the host -1. `doctl compute droplet create waving-hands --region nyc3 \ - --size s-1vcpu-512mb-10gb --image ubuntu-24-04-x64 \ - --ssh-keys --tag-name waving-hands --wait` -2. `scp deploy/setup-droplet.sh deploy/Caddyfile.tmpl root@:/root/ && ssh root@ \ - "bash /root/setup-droplet.sh 'hands.kestrelsnest.social, waving-hands..sslip.io'"` - (point the A record at the new IP first, or leave the real name out until it is). -3. `scp deploy/setup-server.sh deploy/Caddyfile.tmpl root@:/root/ && ssh root@ "bash /root/setup-server.sh"` -4. `deploy/deploy.sh ` +1. `scp deploy/setup-droplet.sh deploy/Caddyfile.tmpl root@:/root/ && ssh root@ \ + "bash /root/setup-droplet.sh 'hands.kestrelsnest.social, hands..sslip.io'"` + It installs Caddy and Node if the host lacks them, writes this game's + site file, and touches nothing of another game's. Point the A record at + the host first, or leave the real name out until it is; running it again + with the new line is how a name is added later. +2. `deploy/deploy.sh ` -The sslip.io hostname works with no DNS at all. To add a real name, point an -A record at the droplet and add the name to the first line of -/etc/caddy/Caddyfile (space separated), then `systemctl reload caddy`; Caddy -fetches the certificate on first request. +A fresh host, when one is needed: `doctl compute droplet create hall --region nyc3 \ +--size s-1vcpu-1gb --image ubuntu-24-04-x64 --ssh-keys \ +--tag-name kestrels-hall --wait`, then step 1. ## Operations scripts on the droplet diff --git a/deploy/pull-reports.sh b/deploy/pull-reports.sh index aa937dc..759c83e 100755 --- a/deploy/pull-reports.sh +++ b/deploy/pull-reports.sh @@ -3,7 +3,7 @@ # to a local folder, and write a digest beside them for reading. # deploy/pull-reports.sh [host] [folder] (default ~/Desktop/waving-hands-reports) set -euo pipefail -HOST="${1:-159.203.98.71}" +HOST="${1:-159.203.115.252}" OUT="${2:-$HOME/Desktop/waving-hands-reports}" mkdir -p "$OUT/images" scp -q "root@$HOST:/var/lib/waving-hands/feedback.jsonl" "$OUT/feedback.jsonl" 2>/dev/null || : > "$OUT/feedback.jsonl" diff --git a/deploy/pulse.sh b/deploy/pulse.sh index 0b78b3f..2b16f4a 100755 --- a/deploy/pulse.sh +++ b/deploy/pulse.sh @@ -33,4 +33,4 @@ else fi echo "--- box" echo "disk: $(df -h / | awk 'NR==2{print $5" used of "$2}'); memory: $(free -m | awk 'NR==2{print $7" MB available of "$2}'); load: $(cut -d' ' -f1-3 /proc/loadavg)" -echo "caddy access log: $(du -sh /var/lib/caddy/access.log 2>/dev/null | cut -f1) live, $(ls /var/lib/caddy/access*.log* 2>/dev/null | wc -l) files" +echo "caddy access log: $(du -sh /var/lib/caddy/waving-hands-access.log 2>/dev/null | cut -f1) live, $(ls /var/lib/caddy/waving-hands-access*.log* 2>/dev/null | wc -l) files" diff --git a/deploy/setup-droplet.sh b/deploy/setup-droplet.sh index 671dde3..02ded9f 100755 --- a/deploy/setup-droplet.sh +++ b/deploy/setup-droplet.sh @@ -1,34 +1,69 @@ #!/usr/bin/env bash -# One-time droplet setup. Copy this script and Caddyfile.tmpl to the droplet -# and run ON the droplet as root: -# bash setup-droplet.sh 'hands.kestrelsnest.social, waving-hands..sslip.io' -# The argument is the Caddy site address line: one name, or several -# separated by commas. Every name must already resolve to this droplet. -# Waving Hands is a static site: Caddy serves the built files and terminates -# TLS. There is no application process to install or supervise. +# Prepare a host for Waving Hands. The host may be shared with other games of +# Kestrel's Hall: each keeps its own Caddy site file in /etc/caddy/sites, its +# own port, service user, data directory and access log, and this script +# touches nothing that belongs to another game. Copy it and Caddyfile.tmpl +# to the host and run ON it as root; safe to run again. +# bash setup-droplet.sh 'hands.kestrelsnest.social, waving-hands..sslip.io' +# The argument is this game's Caddy site address line: one name, or several +# separated by commas. Every name must already resolve to this host. set -euo pipefail -HOST="${1:?usage: setup-droplet.sh }" +HOST="${1:?usage: setup-droplet.sh }" apt-get update -q apt-get install -qy curl rsync -# Caddy (auto-HTTPS) -apt-get install -qy debian-keyring debian-archive-keyring apt-transport-https -curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ - | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg -curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ - | tee /etc/apt/sources.list.d/caddy-stable.list -apt-get update -q && apt-get install -qy caddy +# Caddy (auto-HTTPS), once per host. +if ! command -v caddy >/dev/null; then + apt-get install -qy debian-keyring debian-archive-keyring apt-transport-https + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ + | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ + | tee /etc/apt/sources.list.d/caddy-stable.list + apt-get update -q && apt-get install -qy caddy +fi -# Site directory, readable by Caddy. -mkdir -p /opt/waving-hands/build -chown -R root:caddy /opt/waving-hands -chmod -R g+rX /opt/waving-hands +# Node 22, once per host; every game runs its server on it. +if ! command -v node >/dev/null || [[ "$(node -v)" != v22* ]]; then + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - + apt-get install -qy nodejs +fi + +# The host's Caddyfile only gathers the games' site files. A Caddyfile that +# is neither Caddy's stock one nor this is a single-game host from before the +# shared layout: stop rather than overwrite it. +ROOT_CADDY='# Kestrel'"'"'s Hall: every game on this host brings its own site file. +import sites/*.caddy' +mkdir -p /etc/caddy/sites +if ! grep -q '^import sites/\*.caddy' /etc/caddy/Caddyfile 2>/dev/null; then + if [ -s /etc/caddy/Caddyfile ] && ! grep -q 'root \* /usr/share/caddy' /etc/caddy/Caddyfile; then + echo "/etc/caddy/Caddyfile holds another site; move it into /etc/caddy/sites/ first" >&2 + exit 1 + fi + printf '%s\n' "$ROOT_CADDY" > /etc/caddy/Caddyfile +fi + +# This game: its user, its directories, its site. +# The service user shares the slug. A slug that is already a system group +# (Ubuntu ships "dip", "games", "audio" and others) cannot also be a user: +# stop and say so, and choose another user name in this script and the unit. +if ! id -u waving-hands &>/dev/null; then + if getent group waving-hands >/dev/null; then + echo "a system group is already called waving-hands; pick another service user name" >&2 + exit 1 + fi + useradd -r -m -d /opt/waving-hands-home waving-hands +fi +mkdir -p /opt/waving-hands/build /opt/waving-hands/app /var/lib/waving-hands/rooms /var/log/waving-hands +chown -R root:caddy /opt/waving-hands/build +chmod -R g+rX /opt/waving-hands/build +chown -R waving-hands:waving-hands /var/lib/waving-hands +sed "s|__HOST__|$HOST|" "$(dirname "$0")/Caddyfile.tmpl" > /etc/caddy/sites/waving-hands.caddy -# Caddy: the site and the duel server's paths, from the template beside this script. -sed "s|__HOST__|$HOST|" "$(dirname "$0")/Caddyfile.tmpl" > /etc/caddy/Caddyfile caddy validate --config /etc/caddy/Caddyfile -systemctl reload caddy +# Validation as root touches the access logs; Caddy runs as caddy and must own them. +chown -R caddy:caddy /var/lib/caddy +systemctl reload-or-restart caddy # Firewall: ssh + web only. ufw allow OpenSSH @@ -36,4 +71,4 @@ ufw allow 80/tcp ufw allow 443/tcp ufw --force enable -echo "droplet ready: now run deploy/deploy.sh from your machine" +echo "host ready for waving-hands: now run deploy/deploy.sh from your machine" diff --git a/deploy/setup-server.sh b/deploy/setup-server.sh deleted file mode 100755 index d1269e5..0000000 --- a/deploy/setup-server.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -# Adds the duel server to a droplet that setup-droplet.sh already prepared. -# Copy this script and Caddyfile.tmpl to the droplet and run ON the droplet -# as root; safe to run again. Installs Node 22, creates the service user and -# data directory, and teaches Caddy to hand /api and /ws to the server while -# it keeps serving the static site itself. -set -euo pipefail - -if ! command -v node >/dev/null || [[ "$(node -v)" != v22* ]]; then - curl -fsSL https://deb.nodesource.com/setup_22.x | bash - - apt-get install -qy nodejs -fi - -id -u waving-hands &>/dev/null || useradd -r -m -d /opt/waving-hands-home waving-hands -mkdir -p /opt/waving-hands/app /var/lib/waving-hands/rooms -chown -R waving-hands:waving-hands /var/lib/waving-hands - -# Caddy: rewrite the site's configuration from the template, keeping its -# address line, so the duel server's paths are routed before the files. -HOST_LINE=$(head -1 /etc/caddy/Caddyfile) -sed "s|__HOST__|$HOST_LINE|" "$(dirname "$0")/Caddyfile.tmpl" > /etc/caddy/Caddyfile -caddy validate --config /etc/caddy/Caddyfile -systemctl reload caddy - -echo "server prerequisites ready: now run deploy/deploy.sh from your machine" diff --git a/deploy/verify-ledgers.sh b/deploy/verify-ledgers.sh index 404cac0..8418fec 100755 --- a/deploy/verify-ledgers.sh +++ b/deploy/verify-ledgers.sh @@ -6,7 +6,7 @@ # deploy/verify-ledgers.sh [https://site] set -euo pipefail HOST="${1:?usage: verify-ledgers.sh [https://site]}" -SITE="${2:-https://hands.kestrelsnest.social}" +SITE="${2:-${SITE:-https://hands.kestrelsnest.social}}" DIR="$(mktemp -d)" trap 'rm -rf "$DIR"' EXIT rsync -az "root@$HOST:/var/lib/waving-hands/rooms/" "$DIR/" diff --git a/deploy/visitors.sh b/deploy/visitors.sh index cdddbfb..573bfea 100755 --- a/deploy/visitors.sh +++ b/deploy/visitors.sh @@ -30,7 +30,7 @@ PAGES = {"/": "hall", "/play": "play", "/rules": "rules"} addresses, bots = set(), set() pages, joins, referrers, campaigns, agents = Counter(), Counter(), Counter(), Counter(), Counter() requests = 0 -for path in sorted(glob.glob("/var/lib/caddy/access.log*")): +for path in sorted(glob.glob("/var/lib/caddy/waving-hands-access*.log*")): try: with open(path) as f: for line in f: diff --git a/deploy/waving-hands-rollup.sh b/deploy/waving-hands-rollup.sh index 9790623..7d1ad76 100755 --- a/deploy/waving-hands-rollup.sh +++ b/deploy/waving-hands-rollup.sh @@ -34,7 +34,7 @@ req = human = bot = 0 hips, bips = set(), set() pages, joins, refs, campaigns = collections.Counter(), collections.Counter(), collections.Counter(), collections.Counter() seen_campaign = set() -for f in sorted(glob.glob("/var/lib/caddy/access*.log*")): +for f in sorted(glob.glob("/var/lib/caddy/waving-hands-access*.log*")): if os.path.getmtime(f) < t0: continue opener = gzip.open if f.endswith(".gz") else open with opener(f, "rt", errors="ignore") as fh: