diff --git a/new-game.sh b/new-game.sh index fda0887..7b94432 100755 --- a/new-game.sh +++ b/new-game.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash # Stamp out a new game from the template. # ./new-game.sh "" [port] [domain] -# e.g. ./new-game.sh tumbleweed "Tumbleweed" 8789 tumbleweed.kestrelsnest.social -# Ports so far: wizwar 8787, waving-hands 8788. Each game is its own droplet, -# so the port only has to be unique on your machine for running two locally. +# e.g. ./new-game.sh tumbleweed "Tumbleweed" 8791 tumbleweed.kestrelsnest.social +# Ports so far: wizwar 8787, waving-hands 8788, hnefatafl 8789, dip 8790. The +# games share one host (deploy/README.md), so a new game takes the next free port. set -euo pipefail SLUG="${1:?usage: new-game.sh \"\" [port] [domain]}" NAME="${2:?usage: new-game.sh \"\" [port] [domain]}" @@ -20,7 +20,7 @@ find "$DEST" -depth -name '*__SLUG__*' | while read -r f; do mv "$f" "$(dirname "$f")/$(basename "$f" | sed "s/__SLUG__/$SLUG/g")" done # Then the placeholders inside. __IP__ and the Sentry ids stay until they exist. -find "$DEST" -type f \( -name '*.ts' -o -name '*.svelte' -o -name '*.json' -o -name '*.md' -o -name '*.sh' -o -name '*.tmpl' -o -name '*.service' -o -name '*.cron' -o -name '*.html' -o -name '*.css' -o -name '.gitignore' \) -print0 \ +find "$DEST" -type f \( -name '*.ts' -o -name '*.svelte' -o -name '*.json' -o -name '*.md' -o -name '*.sh' -o -name '*.py' -o -name '*.tmpl' -o -name '*.service' -o -name '*.cron' -o -name '*.html' -o -name '*.css' -o -name '.gitignore' \) -print0 \ | xargs -0 sed -i '' -e "s/__SLUG__/$SLUG/g" -e "s/__NAME__/$NAME/g" -e "s/__PORT__/$PORT/g" -e "s/__DOMAIN__/$DOMAIN/g" cp "$KIT/CONVENTIONS.md" "$DEST/docs-conventions.md" 2>/dev/null || true mkdir -p "$DEST/docs" && mv "$DEST/docs-conventions.md" "$DEST/docs/conventions.md" @@ -42,8 +42,8 @@ Next: Add static/og.png (1200 by 630) and static/apple-touch-icon.png (180 by 180): app.html already points at them, and the static fallback serves the hall's HTML for a missing file, so a share card without the image breaks quietly. - 3. Create the droplet (deploy/README.md), then replace __IP__ everywhere: - grep -rl __IP__ . --exclude-dir=node_modules | xargs sed -i '' 's/__IP__//g' + 3. Add the game to the shared host (deploy/README.md), after replacing __IP__ with its address: + grep -rl __IP__ . --exclude-dir=node_modules | xargs sed -i '' 's/__IP__/159.203.115.252/g' 4. Create a Sentry project (the Sentry MCP can) and put its DSN and project id in deploy/$SLUG.service and deploy/sentry-slack-alert.sh. 5. deploy/deploy.sh diff --git a/template/deploy/Caddyfile.tmpl b/template/deploy/Caddyfile.tmpl index c66f422..1a39223 100644 --- a/template/deploy/Caddyfile.tmpl +++ b/template/deploy/Caddyfile.tmpl @@ -1,42 +1,42 @@ -__HOST__ +__HOST__ { + root * /opt/__SLUG__/build + encode gzip zstd -root * /opt/__SLUG__/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/__SLUG__-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:__PORT__ { + 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:__PORT__ { - 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/template/deploy/README.md b/template/deploy/README.md index c7421d9..01eaeea 100644 --- a/template/deploy/README.md +++ b/template/deploy/README.md @@ -1,22 +1,28 @@ # Deploying __NAME__ -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/__SLUG__/rooms. Production is one DigitalOcean droplet: Caddy -terminates TLS with automatic certificates, serves the static build from -/opt/__SLUG__/build, and proxies /api and /ws to the game server on port -__PORT__, which runs as the `__SLUG__` user under systemd from -/opt/__SLUG__/app. +Every game is a room on a small Node process that keeps it as an +append-only ledger of moves in /var/lib/__SLUG__/rooms. Production is the +shared Kestrel's Hall host: Caddy terminates TLS with automatic +certificates, serves the static build from /opt/__SLUG__/build, and proxies +/api and /ws to the game server on port __PORT__, which runs as the +`__SLUG__` user under systemd from /opt/__SLUG__/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: `__SLUG__` (nyc3, s-1vcpu-512mb-10gb, tag `__SLUG__`), IP __IP__ +- Host: __IP__ - URLs: https://__DOMAIN__ (A record at Hover, where kestrelsnest.social's DNS lives) and https://__SLUG__.__IP__.sslip.io (always works, zero DNS). - Everyday deploy: `deploy/deploy.sh __IP__` @@ -24,21 +30,19 @@ at 300 MB by its unit file, and a room is a few kilobytes of ledger. and the screenshots to ~/Desktop/__SLUG__-reports with a digest; `deploy/report-reply.sh __IP__ "text"` answers one. -## New droplet from scratch +## Adding this game to the host -1. `doctl compute droplet create __SLUG__ --region nyc3 \ - --size s-1vcpu-512mb-10gb --image ubuntu-24-04-x64 \ - --ssh-keys --tag-name __SLUG__ --wait` -2. `scp deploy/setup-droplet.sh deploy/Caddyfile.tmpl root@:/root/ && ssh root@ \ +1. `scp deploy/setup-droplet.sh deploy/Caddyfile.tmpl root@:/root/ && ssh root@ \ "bash /root/setup-droplet.sh '__DOMAIN__, __SLUG__..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 ` + 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/template/deploy/__SLUG__-pulse.sh b/template/deploy/__SLUG__-pulse.sh index 2e56194..330d591 100755 --- a/template/deploy/__SLUG__-pulse.sh +++ b/template/deploy/__SLUG__-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/__SLUG__-access.log 2>/dev/null | cut -f1) live, $(ls /var/lib/caddy/__SLUG__-access*.log* 2>/dev/null | wc -l) files" diff --git a/template/deploy/setup-droplet.sh b/template/deploy/setup-droplet.sh index 823aea9..a866376 100755 --- a/template/deploy/setup-droplet.sh +++ b/template/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 '__DOMAIN__, __SLUG__..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 __NAME__. 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 '__DOMAIN__, __SLUG__..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/__SLUG__/build -chown -R root:caddy /opt/__SLUG__ -chmod -R g+rX /opt/__SLUG__ +# 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 __SLUG__ &>/dev/null; then + if getent group __SLUG__ >/dev/null; then + echo "a system group is already called __SLUG__; pick another service user name" >&2 + exit 1 + fi + useradd -r -m -d /opt/__SLUG__-home __SLUG__ +fi +mkdir -p /opt/__SLUG__/build /opt/__SLUG__/app /var/lib/__SLUG__/rooms /var/log/__SLUG__ +chown -R root:caddy /opt/__SLUG__/build +chmod -R g+rX /opt/__SLUG__/build +chown -R __SLUG__:__SLUG__ /var/lib/__SLUG__ +sed "s|__HOST__|$HOST|" "$(dirname "$0")/Caddyfile.tmpl" > /etc/caddy/sites/__SLUG__.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 __SLUG__: now run deploy/deploy.sh from your machine" diff --git a/template/deploy/setup-server.sh b/template/deploy/setup-server.sh deleted file mode 100755 index d2a1cc4..0000000 --- a/template/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 __SLUG__ &>/dev/null || useradd -r -m -d /opt/__SLUG__-home __SLUG__ -mkdir -p /opt/__SLUG__/app /var/lib/__SLUG__/rooms -chown -R __SLUG__:__SLUG__ /var/lib/__SLUG__ - -# 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/template/deploy/traffic.py b/template/deploy/traffic.py index 6ee5bcc..f4eb073 100644 --- a/template/deploy/traffic.py +++ b/template/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 = "__DOMAIN__" -LOGS = "/var/lib/caddy/access*.log*" +# Each game on a shared host keeps its own access log. +LOGS = "/var/lib/caddy/__SLUG__-access*.log*" def scan(day, logs=LOGS):