From 010f99aa9c344178f75ca07bbfe0cb53b57dcfa9 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Fri, 25 Sep 2026 16:56:33 -0400 Subject: [PATCH] Hnefatafl moves to the shared hall host The new kit setup, its own Caddy site file and access log, and the host's address everywhere. The old droplet forwards to hall until the A record moves. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- .claude/skills/hnefatafl-pulse/SKILL.md | 2 +- .claude/skills/hnefatafl-reports/SKILL.md | 8 +-- .claude/skills/hnefatafl-visitors/SKILL.md | 2 +- deploy/Caddyfile.tmpl | 74 ++++++++++---------- deploy/README.md | 62 +++++++++-------- deploy/hnefatafl-pulse.sh | 2 +- deploy/pull-reports.sh | 2 +- deploy/setup-droplet.sh | 81 +++++++++++++++------- deploy/setup-server.sh | 25 ------- deploy/traffic.py | 3 +- 10 files changed, 138 insertions(+), 123 deletions(-) delete mode 100755 deploy/setup-server.sh diff --git a/.claude/skills/hnefatafl-pulse/SKILL.md b/.claude/skills/hnefatafl-pulse/SKILL.md index f12de01..a979784 100644 --- a/.claude/skills/hnefatafl-pulse/SKILL.md +++ b/.claude/skills/hnefatafl-pulse/SKILL.md @@ -7,7 +7,7 @@ description: The weekly Hnefatafl operations pulse — game server health, error One command on the droplet prints everything; read it as a short report. - ssh root@209.97.148.244 hnefatafl-pulse.sh + ssh root@159.203.115.252 hnefatafl-pulse.sh - **game 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/hnefatafl-reports/SKILL.md b/.claude/skills/hnefatafl-reports/SKILL.md index 20a3cc3..7a28626 100644 --- a/.claude/skills/hnefatafl-reports/SKILL.md +++ b/.claude/skills/hnefatafl-reports/SKILL.md @@ -7,7 +7,7 @@ description: Work the Hnefatafl reports desk — fetch players' bug reports, sho Players file reports from the Report button in a room's masthead (seated or from the gallery). Each lands in `/var/lib/hnefatafl/feedback.jsonl` -on the droplet (209.97.148.244) 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 game to the moment. Replies live in the same file and appear under the report in the player's hall. This desk fetches, @@ -48,7 +48,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@209.97.148.244:/var/lib/hnefatafl/rooms/.jsonl /` +1. **Replay to the pin.** `scp root@159.203.115.252:/var/lib/hnefatafl/rooms/.jsonl /` and replay it with the engine as `deploy/replay-ledgers.ts` does: `game.create(names, start.seed, start.rules, room.options)` then `game.resolve(state, line.inputs)` per turn line, printing the state @@ -72,8 +72,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 games get the fix. Either way add a test in `src/lib/game/*.test.ts` pinning the - corrected behaviour. Deploy with `deploy/deploy.sh 209.97.148.244`. -5. **Reply.** `bash deploy/report-reply.sh 209.97.148.244 "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/hnefatafl-visitors/SKILL.md b/.claude/skills/hnefatafl-visitors/SKILL.md index 48bd970..07e56cb 100644 --- a/.claude/skills/hnefatafl-visitors/SKILL.md +++ b/.claude/skills/hnefatafl-visitors/SKILL.md @@ -10,7 +10,7 @@ host glancing over the hall. ## Gather - ssh root@209.97.148.244 hnefatafl-visitors.sh [YYYY-MM-DD] + ssh root@159.203.115.252 hnefatafl-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 f02d389..5007942 100644 --- a/deploy/Caddyfile.tmpl +++ b/deploy/Caddyfile.tmpl @@ -1,42 +1,42 @@ -__HOST__ +__HOST__ { + root * /opt/hnefatafl/build + encode gzip zstd -root * /opt/hnefatafl/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/hnefatafl-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:8789 { + 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 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:8789 { - 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 -} diff --git a/deploy/README.md b/deploy/README.md index 1d05840..426eca1 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,44 +1,50 @@ # Deploying Hnefatafl -Every game, against a housecarl or a friend, is a room on a small Node -process that keeps it as an append-only ledger of moves in -/var/lib/hnefatafl/rooms. Production is one DigitalOcean droplet: Caddy -terminates TLS with automatic certificates, serves the static build from -/opt/hnefatafl/build, and proxies /api and /ws to the game server on port -8789, which runs as the `hnefatafl` user under systemd from -/opt/hnefatafl/app. +Every game is a room on a small Node process that keeps it as an +append-only ledger of moves in /var/lib/hnefatafl/rooms. Production is the +shared Kestrel's Hall host: Caddy terminates TLS with automatic +certificates, serves the static build from /opt/hnefatafl/build, and proxies +/api and /ws to the game server on port 8789, which runs as the +`hnefatafl` user under systemd from /opt/hnefatafl/app. -## Sizing +## The shared host -The smallest droplet, `s-1vcpu-512mb-10gb` ($4 a month), carries both Caddy -and the game 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 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: `hnefatafl` (nyc3, s-1vcpu-512mb-10gb, tag `hnefatafl`), IP 209.97.148.244 +- Host: 159.203.115.252 - URLs: https://tafl.kestrelsnest.social (A record at Hover, where kestrelsnest.social's - DNS lives) and https://hnefatafl.209.97.148.244.sslip.io (always works, zero DNS). -- Everyday deploy: `deploy/deploy.sh 209.97.148.244` + DNS lives) and https://tafl.159.203.115.252.sslip.io (always works, zero DNS). +- Everyday deploy: `SITE=https://tafl.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 (209.97.148.244) on 2026-09-25. - Players' reports: `deploy/pull-reports.sh` mirrors /var/lib/hnefatafl/feedback.jsonl and the screenshots to ~/Desktop/hnefatafl-reports with a digest; - `deploy/report-reply.sh 209.97.148.244 "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 hnefatafl --region nyc3 \ - --size s-1vcpu-512mb-10gb --image ubuntu-24-04-x64 \ - --ssh-keys --tag-name hnefatafl --wait` -2. `scp deploy/setup-droplet.sh deploy/Caddyfile.tmpl root@:/root/ && ssh root@ \ - "bash /root/setup-droplet.sh 'tafl.kestrelsnest.social, hnefatafl..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 'tafl.kestrelsnest.social, tafl..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/hnefatafl-pulse.sh b/deploy/hnefatafl-pulse.sh index 9f54d5a..5c4687e 100755 --- a/deploy/hnefatafl-pulse.sh +++ b/deploy/hnefatafl-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/hnefatafl-access.log 2>/dev/null | cut -f1) live, $(ls /var/lib/caddy/hnefatafl-access*.log* 2>/dev/null | wc -l) files" diff --git a/deploy/pull-reports.sh b/deploy/pull-reports.sh index 04b6027..bc6ab18 100755 --- a/deploy/pull-reports.sh +++ b/deploy/pull-reports.sh @@ -4,7 +4,7 @@ # repository root: # deploy/pull-reports.sh [host] [folder] (default ~/Desktop/hnefatafl-reports) set -euo pipefail -HOST="${1:-209.97.148.244}" +HOST="${1:-159.203.115.252}" OUT="${2:-$HOME/Desktop/hnefatafl-reports}" mkdir -p "$OUT/images" scp -q "root@$HOST:/var/lib/hnefatafl/feedback.jsonl" "$OUT/feedback.jsonl" 2>/dev/null || : > "$OUT/feedback.jsonl" diff --git a/deploy/setup-droplet.sh b/deploy/setup-droplet.sh index 3299a37..8b690e3 100755 --- a/deploy/setup-droplet.sh +++ b/deploy/setup-droplet.sh @@ -1,36 +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 'tafl.kestrelsnest.social, hnefatafl..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. -# Caddy serves the built files and terminates TLS; setup-server.sh adds the -# game server behind it. +# Prepare a host for Hnefatafl. 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 'tafl.kestrelsnest.social, hnefatafl..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/hnefatafl/build -chown -R root:caddy /opt/hnefatafl -chmod -R g+rX /opt/hnefatafl +# 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 hnefatafl &>/dev/null; then + if getent group hnefatafl >/dev/null; then + echo "a system group is already called hnefatafl; pick another service user name" >&2 + exit 1 + fi + useradd -r -m -d /opt/hnefatafl-home hnefatafl +fi +mkdir -p /opt/hnefatafl/build /opt/hnefatafl/app /var/lib/hnefatafl/rooms /var/log/hnefatafl +chown -R root:caddy /opt/hnefatafl/build +chmod -R g+rX /opt/hnefatafl/build +chown -R hnefatafl:hnefatafl /var/lib/hnefatafl +sed "s|__HOST__|$HOST|" "$(dirname "$0")/Caddyfile.tmpl" > /etc/caddy/sites/hnefatafl.caddy -# Caddy: the site and the game 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 -# Validation as root touches the access log; Caddy runs as caddy and must own it. +# Validation as root touches the access logs; Caddy runs as caddy and must own them. chown -R caddy:caddy /var/lib/caddy -systemctl restart caddy +systemctl reload-or-restart caddy # Firewall: ssh + web only. ufw allow OpenSSH @@ -38,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 hnefatafl: 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 88cc63c..0000000 --- a/deploy/setup-server.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -# Adds the game 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 hnefatafl &>/dev/null || useradd -r -m -d /opt/hnefatafl-home hnefatafl -mkdir -p /opt/hnefatafl/app /var/lib/hnefatafl/rooms -chown -R hnefatafl:hnefatafl /var/lib/hnefatafl - -# Caddy: rewrite the site's configuration from the template, keeping its -# address line, so the game 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/traffic.py b/deploy/traffic.py index 633f4d5..bfac709 100644 --- a/deploy/traffic.py +++ b/deploy/traffic.py @@ -12,7 +12,8 @@ from collections import Counter BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I) PAGES = {"/": "hall", "/rules": "rules", "/guide": "guide"} SITE = "tafl.kestrelsnest.social" -LOGS = "/var/lib/caddy/access*.log*" +# Each game on a shared host keeps its own access log. +LOGS = "/var/lib/caddy/hnefatafl-access*.log*" def scan(day, logs=LOGS):