// The keeper's door: a listener of its own on the loopback, apart from the // port Caddy forwards to, so it answers only on the host itself, and only to // the key in its key file, so another service on a shared host cannot use // it. Its one caller is deploy/wizwar-reseat.sh, which sends the key: // 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 { timingSafeEqual } from "node:crypto"; import { readFileSync } from "node:fs"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import type { GameEvent, PlayerId } from "@wizwar/engine"; import { getRoom, reseat, seatsForKeeper, type Room } from "./rooms"; function send(res: ServerResponse, status: number, body: unknown): void { res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body)); } /** The key is read on each knock, so a key written after the server started is honoured. Absent, the door stays shut. */ function keyGiven(req: IncomingMessage, keyFile: string): boolean { let key: Buffer; try { key = Buffer.from(readFileSync(keyFile, "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); } async function readSeat(req: IncomingMessage): Promise { let raw = ""; for await (const chunk of req) { raw += chunk; if (raw.length > 4096) return ""; } try { const seat = (JSON.parse(raw) as { seat?: unknown }).seat; return typeof seat === "string" ? seat : ""; } catch { return ""; } } export interface KeeperDoor { port: number; publicUrl: string; keyFile: string; /** Lets the caller set adrift any socket still seated on the old token, * and tell the table when the seat comes back from the clockwork. */ onReseat: (room: Room, name: PlayerId, returned?: GameEvent) => void; } async function handle(req: IncomingMessage, res: ServerResponse, { publicUrl, keyFile, onReseat }: KeeperDoor): Promise { const parts = (req.url ?? "/").split("?")[0]!.split("/").filter(Boolean); // Nothing proxied belongs here: a forwarded request is turned away even if a proxy is someday pointed at this port. if (req.headers["x-forwarded-for"] || !keyGiven(req, keyFile)) return send(res, 404, { error: "not here" }); if (parts[0] !== "keeper" || parts[1] !== "rooms" || !parts[2]) return send(res, 404, { error: "not here" }); const room = getRoom(parts[2]); if (!room) return send(res, 404, { error: "no such room" }); if (parts.length === 3 && req.method === "GET") { return send(res, 200, { roomId: room.id, started: room.state !== null, finished: room.state?.phase === "finished", seats: seatsForKeeper(room) }); } if (parts.length === 4 && parts[3] === "reseat" && req.method === "POST") { const name = await readSeat(req); const result = reseat(room, name); if ("error" in result) return send(res, 404, result); onReseat(room, name, result.returned); // The token rides the fragment, which the browser keeps to itself: it reaches neither Caddy's log nor the server. return send(res, 200, { name, link: `${publicUrl}/join/${room.id}#seat=${encodeURIComponent(name)}.${result.token}` }); } return send(res, 404, { error: "not here" }); } /** Open the door on 127.0.0.1, whatever address the game itself listens on. */ export function openKeeperDoor(door: KeeperDoor): Server { const server = createServer((req, res) => { handle(req, res, door).catch((e) => { console.error("keeper door:", e); if (!res.headersSent) send(res, 500, { error: "internal error" }); }); }); server.listen(door.port, "127.0.0.1"); return server; }