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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
This commit is contained in:
Eric Wagoner
2026-10-13 18:27:13 -04:00
co-authored by Claude Opus 5.5
parent 4721b0e97b
commit 7b4080c91f
5 changed files with 41 additions and 8 deletions
+3 -1
View File
@@ -52,7 +52,9 @@ README.
the keeper, and `<slug>-reseat.sh ROOM SEAT` on the host gives the seat
a new token and prints a link carrying it in the fragment. The old token
opens nothing after. The script answers through the game server's
`/keeper` door, which Caddy does not proxy. A spectator at a game in play
`/keeper` door, which Caddy does not proxy and which asks for the key in
`/etc/<slug>/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
+6 -2
View File
@@ -8,8 +8,12 @@
set -euo pipefail
ROOM="${1:?usage: __SLUG__-reseat.sh ROOM [SEAT]}"
DOOR="http://127.0.0.1:__PORT__/keeper/rooms/${ROOM^^}"
[ -s /etc/__SLUG__/keeper.key ] || { echo "No keeper's key at /etc/__SLUG__/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/__SLUG__/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"])
+6
View File
@@ -43,6 +43,12 @@ ssh "root@$HOST" '
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-rollup.sh /usr/local/bin/__SLUG__-rollup.sh
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-backup.sh /usr/local/bin/__SLUG__-backup.sh
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-reseat.sh /usr/local/bin/__SLUG__-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/__SLUG__/keeper.key ]; then
install -d -m 750 -o root -g __SLUG__ /etc/__SLUG__
(umask 037; openssl rand -hex 32 > /etc/__SLUG__/keeper.key)
chown root:__SLUG__ /etc/__SLUG__/keeper.key
fi
install -m 644 -D /opt/__SLUG__/app/deploy/traffic.py /usr/local/lib/__SLUG__/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
+23 -3
View File
@@ -26,13 +26,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/__SLUG__-reseat.sh):
// door answers only on the host itself, and only to the key in KEEPER_KEY_FILE
// (deploy/__SLUG__-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';
@@ -49,6 +52,8 @@ const PUBLIC_URL = (process.env.PUBLIC_URL ?? 'https://__DOMAIN__').replace(/\/$
/** 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/__SLUG__/keeper.key';
const BODY_LIMIT = 16 * 1024;
// Errors go to Sentry when a DSN is set; the SDK drops them otherwise.
@@ -190,9 +195,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<void> {
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') {
+3 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { goto, replaceState } from '$app/navigation';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import Board from '$lib/components/Board.svelte';
import Lobby from '$lib/components/Lobby.svelte';
@@ -33,7 +33,8 @@
room = null;
error = '';
const sent = seatInLink(location.hash);
if (sent) replaceState(location.pathname + location.search, {});
// The browser's own call: this runs as the page first loads, before the router could take one.
if (sent) history.replaceState(history.state, '', location.pathname + location.search);
const held = seatFor(id);
const take = (s: HeldSeat) =>
Room.resume(id, s).then((r) => {