From a2bdf7a82593f65a45cfe7593382ae019a8c21fb Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 13 Oct 2026 18:28:28 -0400 Subject: [PATCH] The keeper's door asks for a key only root and the game's user can read, so another service on the host cannot re-seat anyone; the deploy makes the key once and the reseat script sends it from a file descriptor; and a seat link is taken out of the address bar with the browser's own call, since the page reads it before the router starts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m --- deploy/deploy.sh | 6 ++++++ deploy/hnefatafl-reseat.sh | 8 ++++++-- docs/conventions.md | 7 ++++--- server/src/index.ts | 26 +++++++++++++++++++++++--- src/routes/join/[code]/+page.svelte | 5 +++-- 5 files changed, 42 insertions(+), 10 deletions(-) diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 13e2548..170675a 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -43,6 +43,12 @@ ssh "root@$HOST" ' install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-rollup.sh /usr/local/bin/hnefatafl-rollup.sh install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-backup.sh /usr/local/bin/hnefatafl-backup.sh install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-reseat.sh /usr/local/bin/hnefatafl-reseat.sh + # The key to the keeper door: made once, readable by root (the reseat script) and the game user (the server) alone. + if [ ! -s /etc/hnefatafl/keeper.key ]; then + install -d -m 750 -o root -g hnefatafl /etc/hnefatafl + (umask 037; openssl rand -hex 32 > /etc/hnefatafl/keeper.key) + chown root:hnefatafl /etc/hnefatafl/keeper.key + fi install -m 644 -D /opt/hnefatafl/app/deploy/traffic.py /usr/local/lib/hnefatafl/traffic.py # On a host the hall shares, its hall-nightly.sh runs the rollup and backup # of every game and checks in with Sentry once for all; a game on a box of diff --git a/deploy/hnefatafl-reseat.sh b/deploy/hnefatafl-reseat.sh index 8f3280a..4f4511b 100755 --- a/deploy/hnefatafl-reseat.sh +++ b/deploy/hnefatafl-reseat.sh @@ -8,8 +8,12 @@ set -euo pipefail ROOM="${1:?usage: hnefatafl-reseat.sh ROOM [SEAT]}" DOOR="http://127.0.0.1:8789/keeper/rooms/${ROOM^^}" +[ -s /etc/hnefatafl/keeper.key ] || { echo "No keeper's key at /etc/hnefatafl/keeper.key; a deploy makes it." >&2; exit 1; } +# The key goes as a header read from a file descriptor, never on a command line another user could see. +KEY="@/dev/fd/3" +exec 3< <(printf 'x-keeper-key: %s\n' "$(cat /etc/hnefatafl/keeper.key)") if [ $# -lt 2 ]; then - curl -s "$DOOR" | python3 -c ' + curl -s -H "$KEY" "$DOOR" | python3 -c ' import datetime, json, sys r = json.load(sys.stdin) if "error" in r: sys.exit(r["error"]) @@ -19,7 +23,7 @@ for s in r["seats"]: print(" %-3s %-24s %s" % (s["id"], s["name"], when)) ' else - curl -s -X POST -H 'content-type: application/json' -d "{\"seat\":\"$2\"}" "$DOOR/reseat" | python3 -c ' + curl -s -X POST -H "$KEY" -H 'content-type: application/json' -d "{\"seat\":\"$2\"}" "$DOOR/reseat" | python3 -c ' import json, sys r = json.load(sys.stdin) if "error" in r: sys.exit(r["error"]) diff --git a/docs/conventions.md b/docs/conventions.md index 9125310..c657250 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -43,9 +43,10 @@ README. a new token and prints a link carrying it in the fragment. The old token opens nothing after. A link is kept only once the server takes it, and never over a seat the browser holds that still opens. The script answers - through the game server's `/keeper` door, which Caddy does not proxy. A - spectator at a game in play is told both ways back, and the hall asks - before forgetting a game in play. + through the game server's `/keeper` door, which Caddy does not proxy and + which asks for the key in `/etc//keeper.key`, made by the deploy and + readable by root and the game's user alone. A spectator at a game in play + is told both ways back, and the hall asks before forgetting a game in play. - **Held seats are doubted before they are dropped**: the hall forgets a seat only on the second refusal in a row from the server. A restart, a stale answer or the wrong backend answer with ignorance, and ignorance diff --git a/server/src/index.ts b/server/src/index.ts index 5b44817..94910fe 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -24,13 +24,16 @@ // a seat in the Peanut Gallery, counted for the table // // Caddy serves the static site and proxies /api and /ws here. The keeper's -// door answers only on the host itself (deploy/hnefatafl-reseat.sh): +// door answers only on the host itself, and only to the key in KEEPER_KEY_FILE +// (deploy/hnefatafl-reseat.sh sends it): // // GET /keeper/rooms/:id who sits at the table, and when each person last moved or spoke // POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them import * as Sentry from '@sentry/node'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { timingSafeEqual } from 'node:crypto'; +import { readFileSync } from 'node:fs'; import { dirname } from 'node:path'; import { WebSocketServer, WebSocket } from 'ws'; import { game } from '../../src/lib/game'; @@ -47,6 +50,8 @@ const PUBLIC_URL = (process.env.PUBLIC_URL ?? 'https://tafl.kestrelsnest.social' /** The keeper of the site, whom a table may call to a seat, and where they sleep. */ const KEEPER = process.env.KEEPER ?? 'the keeper'; const KEEPER_TZ = process.env.KEEPER_TZ ?? 'America/New_York'; +/** The keeper's key, readable by root and this game's user alone, so another service on the host cannot use the keeper's door. Absent, the door stays shut. */ +const KEEPER_KEY_FILE = process.env.KEEPER_KEY_FILE ?? '/etc/hnefatafl/keeper.key'; const BODY_LIMIT = 16 * 1024; // Errors go to Sentry when a DSN is set; the SDK drops them otherwise. @@ -188,9 +193,24 @@ async function handleReports(req: IncomingMessage, res: ServerResponse, parts: s throw new RoomError('Not here.', 404); } -/** The keeper's door. Caddy proxies only /api and /ws, so it is reached from the host alone; a request Caddy forwarded is turned away besides. */ +/** The key the keeper's door asks for, read on each knock so a key written after the server started is honoured. */ +function keeperKeyGiven(req: IncomingMessage): boolean { + let key: Buffer; + try { + key = Buffer.from(readFileSync(KEEPER_KEY_FILE, 'utf8').trim()); + } catch { + return false; + } + const given = Buffer.from(String(req.headers['x-keeper-key'] ?? '')); + return key.length > 0 && given.length === key.length && timingSafeEqual(given, key); +} + +/** + * The keeper's door. Caddy proxies only /api and /ws, so it is reached from the host alone; a request Caddy + * forwarded is turned away besides, and so is one without the keeper's key. + */ async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: string[]): Promise { - if (req.headers['x-forwarded-for'] || parts[1] !== 'rooms') throw new RoomError('Not here.', 404); + if (req.headers['x-forwarded-for'] || parts[1] !== 'rooms' || !keeperKeyGiven(req)) throw new RoomError('Not here.', 404); const room = rooms.get(parts[2] ?? ''); if (parts.length === 3 && req.method === 'GET') return send(res, 200, { roomId: room.id, started: !!room.state, seats: rooms.seatsForKeeper(room) }); if (parts[3] === 'reseat' && parts.length === 4 && req.method === 'POST') { diff --git a/src/routes/join/[code]/+page.svelte b/src/routes/join/[code]/+page.svelte index 8e3ae50..080c27a 100644 --- a/src/routes/join/[code]/+page.svelte +++ b/src/routes/join/[code]/+page.svelte @@ -1,5 +1,5 @@