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:
1 parent
b51934ecd6
commit
21d9c12f48
5 files changed
+41
-8
No files matched your search
+3
-1
@@ -54,7 +54,9 @@ A fresh host, when one is needed: `doctl compute droplet create hall --region ny
|
|||||||
- `waving-hands-visitors.sh [day]`: who is here now and who came that day.
|
- `waving-hands-visitors.sh [day]`: who is here now and who came that day.
|
||||||
- `waving-hands-reseat.sh ROOM [SEAT]`: who sits at a table; with a seat, a new
|
- `waving-hands-reseat.sh ROOM [SEAT]`: who sits at a table; with a seat, a new
|
||||||
token for a player locked out of it, as a link to send them. It answers
|
token for a player locked out of it, as a link to send them. It answers
|
||||||
through the duel server's `/keeper` door, which Caddy does not proxy.
|
through the duel server's `/keeper` door, which Caddy does not proxy and
|
||||||
|
which asks for the key in /etc/waving-hands/keeper.key, made by the deploy
|
||||||
|
and readable by root and the waving-hands user alone.
|
||||||
- `waving-hands-pulse.sh`: the weekly health check (service, errors, rollup
|
- `waving-hands-pulse.sh`: the weekly health check (service, errors, rollup
|
||||||
trend, backup, box).
|
trend, backup, box).
|
||||||
- `waving-hands-rollup.sh [day]`: one JSON line per day of counts, run
|
- `waving-hands-rollup.sh [day]`: one JSON line per day of counts, run
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ ssh "root@$HOST" '
|
|||||||
install -m 755 /opt/waving-hands/app/deploy/waving-hands-rollup.sh /usr/local/bin/waving-hands-rollup.sh
|
install -m 755 /opt/waving-hands/app/deploy/waving-hands-rollup.sh /usr/local/bin/waving-hands-rollup.sh
|
||||||
install -m 755 /opt/waving-hands/app/deploy/waving-hands-backup.sh /usr/local/bin/waving-hands-backup.sh
|
install -m 755 /opt/waving-hands/app/deploy/waving-hands-backup.sh /usr/local/bin/waving-hands-backup.sh
|
||||||
install -m 755 /opt/waving-hands/app/deploy/waving-hands-reseat.sh /usr/local/bin/waving-hands-reseat.sh
|
install -m 755 /opt/waving-hands/app/deploy/waving-hands-reseat.sh /usr/local/bin/waving-hands-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/waving-hands/keeper.key ]; then
|
||||||
|
install -d -m 750 -o root -g waving-hands /etc/waving-hands
|
||||||
|
(umask 037; openssl rand -hex 32 > /etc/waving-hands/keeper.key)
|
||||||
|
chown root:waving-hands /etc/waving-hands/keeper.key
|
||||||
|
fi
|
||||||
# On a host the hall shares, its hall-nightly.sh runs the rollup and backup
|
# 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
|
# of every game and checks in with Sentry once for all; a game on a box of
|
||||||
# its own schedules its own.
|
# its own schedules its own.
|
||||||
|
|||||||
@@ -8,8 +8,12 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ROOM="${1:?usage: waving-hands-reseat.sh ROOM [SEAT]}"
|
ROOM="${1:?usage: waving-hands-reseat.sh ROOM [SEAT]}"
|
||||||
DOOR="http://127.0.0.1:8788/keeper/rooms/${ROOM^^}"
|
DOOR="http://127.0.0.1:8788/keeper/rooms/${ROOM^^}"
|
||||||
|
[ -s /etc/waving-hands/keeper.key ] || { echo "No keeper's key at /etc/waving-hands/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/waving-hands/keeper.key)")
|
||||||
if [ $# -lt 2 ]; then
|
if [ $# -lt 2 ]; then
|
||||||
curl -s "$DOOR" | python3 -c '
|
curl -s -H "$KEY" "$DOOR" | python3 -c '
|
||||||
import datetime, json, sys
|
import datetime, json, sys
|
||||||
r = json.load(sys.stdin)
|
r = json.load(sys.stdin)
|
||||||
if "error" in r: sys.exit(r["error"])
|
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))
|
print(" %-3s %-24s %s" % (s["id"], s["name"], when))
|
||||||
'
|
'
|
||||||
else
|
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
|
import json, sys
|
||||||
r = json.load(sys.stdin)
|
r = json.load(sys.stdin)
|
||||||
if "error" in r: sys.exit(r["error"])
|
if "error" in r: sys.exit(r["error"])
|
||||||
|
|||||||
+23
-3
@@ -24,13 +24,16 @@
|
|||||||
// POST /api/reports/:id/answer {roomId, token, text} your word under the keeper's reply
|
// 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
|
// 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
|
// 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
|
// 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 * as Sentry from '@sentry/node';
|
||||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
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 { WebSocketServer, WebSocket } from 'ws';
|
||||||
import { RateLimit } from './ratelimit';
|
import { RateLimit } from './ratelimit';
|
||||||
import { IMAGE_MAX_BYTES, Reports } from './reports';
|
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. */
|
/** 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 = process.env.KEEPER ?? 'the keeper';
|
||||||
const KEEPER_TZ = process.env.KEEPER_TZ ?? 'America/New_York';
|
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;
|
const BODY_LIMIT = 16 * 1024;
|
||||||
|
|
||||||
// Errors go to Sentry when a DSN is set; the SDK drops them otherwise.
|
// 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> {
|
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] ?? '');
|
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.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') {
|
if (parts[3] === 'reseat' && parts.length === 4 && req.method === 'POST') {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto, replaceState } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import Board from '$lib/components/Board.svelte';
|
import Board from '$lib/components/Board.svelte';
|
||||||
import ReportSlip from '$lib/components/ReportSlip.svelte';
|
import ReportSlip from '$lib/components/ReportSlip.svelte';
|
||||||
@@ -79,7 +79,8 @@
|
|||||||
room = null;
|
room = null;
|
||||||
error = '';
|
error = '';
|
||||||
const sent = seatInLink(location.hash);
|
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 held = seatFor(id);
|
||||||
const take = (s: HeldSeat) =>
|
const take = (s: HeldSeat) =>
|
||||||
Duel.resumeRoom(id, s).then((remote) => {
|
Duel.resumeRoom(id, s).then((remote) => {
|
||||||
|
|||||||
Reference in new issue
Block a user