The keeper's door asks for a key only root and the waving-hands 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 WagonerandClaude Opus 5.5 committed 2026-10-13 18:28:25 -04:00
1 parent b51934ecd6
commit 21d9c12f48
5 files changed
+41 -8

No files matched your search

+23 -3
View File
@@ -24,13 +24,16 @@
// POST /api/reports/:id/answer {roomId, token, text} your word under the keeper's reply
//
// Caddy serves the static site and proxies /api and /ws here. The keeper's
// door answers only on the host itself (deploy/waving-hands-reseat.sh):
// door answers only on the host itself, and only to the key in KEEPER_KEY_FILE
// (deploy/waving-hands-reseat.sh sends it):
//
// GET /keeper/rooms/:id who sits at the table, and when each wizard 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 { WebSocketServer, WebSocket } from 'ws';
import { RateLimit } from './ratelimit';
import { IMAGE_MAX_BYTES, Reports } from './reports';
@@ -45,6 +48,8 @@ const DATA_DIR = process.env.WH_DATA_DIR ?? '../data/rooms';
/** 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/waving-hands/keeper.key';
const BODY_LIMIT = 16 * 1024;
// Errors go to Sentry when a DSN is set; the SDK drops them otherwise.
@@ -199,9 +204,24 @@ function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
});
}
/** 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') {