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):