diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cf3f6e556..69f74a1ff 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -139,6 +139,7 @@ jobs: OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }} API_KEY: ${{ secrets.API_KEY }} + ADMIN_BOT_API_KEY: ${{ secrets.ADMIN_BOT_API_KEY }} NUM_WORKERS: ${{ vars.NUM_WORKERS }} TURNSTILE_SITE_KEY: ${{ vars.TURNSTILE_SITE_KEY }} SERVER_HOST_MASTERS: ${{ secrets.SERVER_HOST_MASTERS }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c5f757763..cbaac134e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,6 +72,7 @@ jobs: OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }} API_KEY: ${{ secrets.API_KEY }} + ADMIN_BOT_API_KEY: ${{ secrets.ADMIN_BOT_API_KEY }} NUM_WORKERS: ${{ vars.NUM_WORKERS }} TURNSTILE_SITE_KEY: ${{ vars.TURNSTILE_SITE_KEY }} SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }} @@ -124,6 +125,7 @@ jobs: OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }} API_KEY: ${{ secrets.API_KEY }} + ADMIN_BOT_API_KEY: ${{ secrets.ADMIN_BOT_API_KEY }} NUM_WORKERS: ${{ vars.NUM_WORKERS }} TURNSTILE_SITE_KEY: ${{ vars.TURNSTILE_SITE_KEY }} SERVER_HOST_FALK2: ${{ secrets.SERVER_HOST_FALK2 }} @@ -176,6 +178,7 @@ jobs: OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }} API_KEY: ${{ secrets.API_KEY }} + ADMIN_BOT_API_KEY: ${{ secrets.ADMIN_BOT_API_KEY }} NUM_WORKERS: ${{ vars.NUM_WORKERS }} TURNSTILE_SITE_KEY: ${{ vars.TURNSTILE_SITE_KEY }} SERVER_HOST_FALK2: ${{ secrets.SERVER_HOST_FALK2 }} @@ -228,6 +231,7 @@ jobs: OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }} API_KEY: ${{ secrets.API_KEY }} + ADMIN_BOT_API_KEY: ${{ secrets.ADMIN_BOT_API_KEY }} NUM_WORKERS: ${{ vars.NUM_WORKERS }} TURNSTILE_SITE_KEY: ${{ vars.TURNSTILE_SITE_KEY }} SERVER_HOST_FALK2: ${{ secrets.SERVER_HOST_FALK2 }} diff --git a/deploy.sh b/deploy.sh index 1a78dbd0f..870b472dd 100755 --- a/deploy.sh +++ b/deploy.sh @@ -140,6 +140,7 @@ HOST=$HOST GHCR_IMAGE=$GHCR_IMAGE GHCR_TOKEN=$GHCR_TOKEN API_KEY=$API_KEY +ADMIN_BOT_API_KEY=$ADMIN_BOT_API_KEY DOMAIN=$DOMAIN SUBDOMAIN=$SUBDOMAIN CDN_BASE=$CDN_BASE diff --git a/nginx.conf b/nginx.conf index 05dfb7b49..b9b51e91a 100644 --- a/nginx.conf +++ b/nginx.conf @@ -100,6 +100,17 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # Admin bot create-game: same random-worker balancing as /api/create_game. + # The worker checks the admin bot key, mints a self-owned id, and returns it. + location = /api/adminbot/create_game { + proxy_pass http://openfront_workers; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # Static file handling with proper MIME types and consistent caching location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|woff|woff2|ttf|eot)$ { proxy_pass http://127.0.0.1:3000; diff --git a/package.json b/package.json index 0417293f1..ac56f6e85 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "build-prod": "concurrently --kill-others-on-fail \"tsc --noEmit\" \"vite build\"", "start:client": "vite", "start:server": "tsx src/server/Server.ts", - "start:server-dev": "cross-env GAME_ENV=dev NUM_WORKERS=2 TURNSTILE_SITE_KEY=1x00000000000000000000AA API_KEY=WARNING_DEV_API_KEY_DO_NOT_USE_IN_PRODUCTION DOMAIN=localhost GIT_COMMIT=DEV tsx src/server/Server.ts", + "start:server-dev": "cross-env GAME_ENV=dev NUM_WORKERS=2 TURNSTILE_SITE_KEY=1x00000000000000000000AA API_KEY=WARNING_DEV_API_KEY_DO_NOT_USE_IN_PRODUCTION ADMIN_BOT_API_KEY=WARNING_DEV_ADMIN_BOT_KEY_DO_NOT_USE_IN_PRODUCTION DOMAIN=localhost GIT_COMMIT=DEV tsx src/server/Server.ts", "dev": "cross-env GAME_ENV=dev concurrently \"npm run start:client\" \"npm run start:server-dev\"", "dev:host": "cross-env GAME_ENV=dev VITE_HOST=lan concurrently \"npm run start:client\" \"npm run start:server-dev\"", "dev:staging": "cross-env GAME_ENV=dev API_DOMAIN=api.openfront.dev concurrently \"npm run start:client\" \"npm run start:server-dev\"", diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 86a2406aa..8eba535cb 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -490,7 +490,7 @@ export const ToggleGameStartTimerIntentSchema = z.object({ type: z.literal("toggle_game_start_timer"), }); -const IntentSchema = z.discriminatedUnion("type", [ +export const IntentSchema = z.discriminatedUnion("type", [ AttackIntentSchema, CancelAttackIntentSchema, SpawnIntentSchema, @@ -522,6 +522,12 @@ const IntentSchema = z.discriminatedUnion("type", [ export const StampedIntentSchema = IntentSchema.and(z.object({ clientID: ID })); export type StampedIntent = Intent & { clientID: ClientID }; +// Placeholder clientID stamped onto admin-bot intents (HTTP admin API). The bot +// is not a player, but toggle_pause — the one bot intent that reaches the turn +// queue — needs a valid clientID. Chosen so it can never collide with a real id: +// generateID() omits 0/l/I/O, and this contains I and O. +export const ADMIN_BOT_CLIENT_ID: ClientID = "ADMINBOT"; + // // Server utility types // diff --git a/src/server/AdminBotRoutes.ts b/src/server/AdminBotRoutes.ts new file mode 100644 index 000000000..4e0d472a9 --- /dev/null +++ b/src/server/AdminBotRoutes.ts @@ -0,0 +1,131 @@ +import crypto from "crypto"; +import type { + Express, + NextFunction, + Request, + RequestHandler, + Response, +} from "express"; +import type { Logger } from "winston"; +import { z } from "zod"; +import { GameType } from "../core/game/Game"; +import { + ADMIN_BOT_CLIENT_ID, + GameConfigSchema, + ID, + IntentSchema, +} from "../core/Schemas"; +import type { GameManager } from "./GameManager"; +import { ServerEnv } from "./ServerEnv"; + +function timingSafeEqualStr(a: string, b: string): boolean { + const ab = Buffer.from(a); + const bb = Buffer.from(b); + if (ab.length !== bb.length) return false; + return crypto.timingSafeEqual(ab, bb); +} + +// Gate for the admin bot HTTP API. 404 when the feature is disabled (key unset) +// so the routes aren't advertised; 401 on a missing/incorrect key. +export const requireAdminBotKey: RequestHandler = ( + req: Request, + res: Response, + next: NextFunction, +) => { + const expected = ServerEnv.adminBotKey(); + if (expected === undefined) { + res.status(404).end(); + return; + } + const provided = req.headers[ServerEnv.adminBotHeader()]; + if (typeof provided !== "string" || !timingSafeEqualStr(provided, expected)) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + next(); +}; + +export function registerAdminBotRoutes(opts: { + app: Express; + gm: GameManager; + workerId: number; + log: Logger; +}) { + const { app, gm, workerId, log } = opts; + + // Validate game id format and that this worker owns it. Returns false and + // sends the error response when the id is bad/misrouted. + const ownsGame = (id: string, res: Response): boolean => { + if (!ID.safeParse(id).success) { + res.status(400).json({ error: "Invalid game ID" }); + return false; + } + if (ServerEnv.workerIndex(id) !== workerId) { + res.status(400).json({ error: "Worker, game id mismatch" }); + return false; + } + return true; + }; + + // Create a private game. The worker mints a self-owned id and returns it, so + // the bot doesn't need to know the sharding. nginx (and the vite dev proxy) + // randomly route here to spread new games across workers. + app.post("/api/adminbot/create_game", requireAdminBotKey, (req, res) => { + const parsed = GameConfigSchema.partial().safeParse(req.body ?? {}); + if (!parsed.success) { + return res.status(400).json({ error: z.prettifyError(parsed.error) }); + } + const config = parsed.data; + // Private only: reject Public and Singleplayer. An omitted gameType defaults + // to Private in createGame, so it's allowed through. + if (config.gameType !== undefined && config.gameType !== GameType.Private) { + return res + .status(400) + .json({ error: "admin bot can only create private games" }); + } + + const id = ServerEnv.generateGameIdForWorker(workerId); + if (id === null) { + log.warn(`admin bot: failed to mint game id on worker ${workerId}`); + return res.status(500).json({ error: "Could not allocate game id" }); + } + + const game = gm.createGame(id, config, undefined); + if (game === null) { + return res.status(409).json({ error: "Game ID already exists" }); + } + log.info(`admin bot created game ${id}`); + res.json({ + ...game.gameInfo(), + workerIndex: workerId, + workerPath: ServerEnv.workerPath(id), + }); + }); + + // Send an intent. Honors the lobby-management intents; everything else 400. + app.post("/api/adminbot/game/:id/intent", requireAdminBotKey, (req, res) => { + const id = req.params.id as string; + if (!ownsGame(id, res)) return; + + const parsed = IntentSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: z.prettifyError(parsed.error) }); + } + const game = gm.game(id); + if (game === null) { + return res.status(404).json({ error: "Game not found" }); + } + + const result = game.handleIntent(parsed.data, { + clientID: ADMIN_BOT_CLIENT_ID, + isLobbyCreator: false, + isAdmin: true, + isAdminBot: true, + }); + if (result.status !== 200) { + return res.status(result.status).json({ error: result.error ?? "error" }); + } + log.info(`admin bot intent ${parsed.data.type} on game ${id}`); + res.json(game.gameInfo()); + }); +} diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 9a6a6476d..ea69afcae 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -51,7 +51,7 @@ export class GameManager { createGame( id: GameID, - gameConfig: GameConfig | undefined, + gameConfig: Partial | undefined, creatorPersistentID?: string, startsAt?: number, publicGameType?: PublicGameType, diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index b84aec37f..2672c87b7 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -13,6 +13,7 @@ import { GameInfo, GameStartInfo, GameStartInfoSchema, + Intent, PlayerRecord, PublicGameType, ServerDesyncSchema, @@ -35,6 +36,23 @@ export enum GamePhase { Finished = "FINISHED", } +// Identity + authority for an intent, supplied by whoever dispatched it: a +// per-connection websocket client, or the trusted admin-bot HTTP API. +export interface IntentActor { + clientID: ClientID; // stamped onto the intent + isLobbyCreator: boolean; + isAdmin: boolean; // role-based admin/root (also true for the admin bot) + isAdminBot: boolean; // the trusted admin-bot HTTP API +} + +// Outcome of dispatching an intent. `status` is an HTTP-style code: 200 on +// success. The admin-bot route maps a non-200 straight to its response; the +// websocket path logs it and drops the message. +export interface IntentOutcome { + status: number; + error?: string; +} + const KICK_REASON_DUPLICATE_SESSION = "kick_reason.duplicate_session"; const KICK_REASON_LOBBY_CREATOR = "kick_reason.lobby_creator"; const KICK_REASON_ADMIN = "kick_reason.admin"; @@ -187,9 +205,128 @@ export class GameServer { if (gameConfig.waterNukes !== undefined) { this.gameConfig.waterNukes = gameConfig.waterNukes ?? undefined; } + // Unconditional on purpose: the host clears cheats by omitting hostCheats + // (the full config it sends has hostCheats: undefined when the toggle is + // off), so `undefined` here means "clear", not "leave unchanged". this.gameConfig.hostCheats = gameConfig.hostCheats; } + // Dispatch a control/gameplay intent from either a websocket client or the + // trusted admin-bot HTTP API. `actor` carries the authority; the per-intent + // actions and game-state guards live here. Returns an HTTP-style outcome the + // caller maps (the bot route -> response, the websocket path -> a log). + public handleIntent(intent: Intent, actor: IntentActor): IntentOutcome { + const stamped: StampedIntent = { ...intent, clientID: actor.clientID }; + + // The admin bot only manages private games. + if (actor.isAdminBot && this.isPublic()) { + return { status: 403, error: "admin bot cannot act on public games" }; + } + + switch (stamped.type) { + case "mark_disconnected": + return { status: 400, error: "mark_disconnected is server-internal" }; + + case "kick_player": { + if (!actor.isLobbyCreator && !actor.isAdmin) { + return { + status: 403, + error: "only the lobby creator or an admin can kick players", + }; + } + if (stamped.clientID === stamped.target) { + return { status: 400, error: "cannot kick yourself" }; + } + const reason = + actor.isAdmin && !actor.isLobbyCreator + ? KICK_REASON_ADMIN + : KICK_REASON_LOBBY_CREATOR; + this.log.info("player kicked", { + kicker: stamped.clientID, + target: stamped.target, + isAdmin: actor.isAdmin, + isAdminBot: actor.isAdminBot, + gameID: this.id, + }); + this.kickClient(stamped.target, reason); + return { status: 200 }; + } + + case "update_game_config": { + if (!actor.isLobbyCreator && !actor.isAdminBot) { + return { + status: 403, + error: "only the lobby creator can update game config", + }; + } + if (this.isPublic()) { + return { status: 403, error: "cannot update a public game" }; + } + if (this.hasStarted()) { + return { status: 409, error: "game already started" }; + } + if (stamped.config.gameType === GameType.Public) { + return { status: 400, error: "cannot change a game to public" }; + } + this.updateGameConfig(stamped.config); + return { status: 200 }; + } + + case "toggle_game_start_timer": { + if (!actor.isLobbyCreator && !actor.isAdminBot) { + return { status: 403, error: "only the lobby creator can start" }; + } + if (this.isPublic()) { + return { status: 403, error: "cannot start a public game" }; + } + if (this.hasStarted()) { + return { status: 409, error: "game already started" }; + } + if (this.startsAt) { + this.startsAt = undefined; + } else { + this.setStartsAt( + Date.now() + (this.gameConfig.startDelay ?? 0) * 1000, + ); + } + return { status: 200 }; + } + + case "toggle_pause": { + if (!actor.isLobbyCreator && !actor.isAdminBot) { + return { status: 403, error: "only the lobby creator can pause" }; + } + // Pausing only makes sense once the game is running. + if (!this.hasStarted()) { + return { status: 409, error: "game not started" }; + } + // Pausing: flush the intent into a turn before isPaused short-circuits + // endTurn(). Unpausing: clear the flag first so the next turn runs. + if (stamped.paused) { + this.addIntent(stamped); + this.endTurn(); + this.isPaused = true; + } else { + this.isPaused = false; + this.addIntent(stamped); + this.endTurn(); + } + return { status: 200 }; + } + + default: { + // Gameplay intents: websocket players only, into the turn queue. + if (actor.isAdminBot) { + return { status: 400, error: "intent not permitted for admin bot" }; + } + if (!this.isPaused) { + this.addIntent(stamped); + } + return { status: 200 }; + } + } + } + private isKicked(clientID: ClientID): boolean { const persistentID = this.allClients.get(clientID)?.persistentID; return ( @@ -405,183 +542,20 @@ export class GameServer { break; } case "intent": { - // Server stamps clientID from the authenticated connection - const stampedIntent = { - ...clientMsg.intent, + // Server stamps clientID from the authenticated connection. + const outcome = this.handleIntent(clientMsg.intent, { clientID: client.clientID, - }; - switch (stampedIntent.type) { - case "mark_disconnected": { - this.log.warn( - `Should not receive mark_disconnected intent from client`, - ); - return; - } - - // Handle kick_player intent via WebSocket - case "kick_player": { - const isLobbyCreator = client.clientID === this.lobbyCreatorID; - const isAdmin = isAdminRole(client.role); - - // Check if the authenticated client is the lobby creator or admin - if (!isLobbyCreator && !isAdmin) { - this.log.warn( - `Only lobby creator or admin can kick players`, - { - clientID: client.clientID, - creatorID: this.lobbyCreatorID, - target: stampedIntent.target, - gameID: this.id, - }, - ); - return; - } - - // Don't allow kicking yourself - if (client.clientID === stampedIntent.target) { - this.log.warn(`Cannot kick yourself`, { - clientID: client.clientID, - }); - return; - } - - // Log and execute the kick - this.log.info(`Player initiated kick`, { - kickerID: client.clientID, - isAdmin, - target: stampedIntent.target, - gameID: this.id, - kickMethod: "websocket", - }); - - this.kickClient( - stampedIntent.target, - isAdmin && !isLobbyCreator - ? KICK_REASON_ADMIN - : KICK_REASON_LOBBY_CREATOR, - ); - return; - } - case "update_game_config": { - // Only lobby creator can update config - if (client.clientID !== this.lobbyCreatorID) { - this.log.warn(`Only lobby creator can update game config`, { - clientID: client.clientID, - creatorID: this.lobbyCreatorID, - gameID: this.id, - }); - return; - } - - if (this.isPublic()) { - this.log.warn(`Cannot update public game via WebSocket`, { - gameID: this.id, - clientID: client.clientID, - }); - return; - } - - if (this.hasStarted()) { - this.log.warn( - `Cannot update game config after it has started`, - { - gameID: this.id, - clientID: client.clientID, - }, - ); - return; - } - - if (stampedIntent.config.gameType === GameType.Public) { - this.log.warn(`Cannot update game to public via WebSocket`, { - gameID: this.id, - clientID: client.clientID, - }); - return; - } - - this.log.info( - `Lobby creator updated game config via WebSocket`, - { - creatorID: client.clientID, - gameID: this.id, - }, - ); - - this.updateGameConfig(stampedIntent.config); - return; - } - case "toggle_game_start_timer": { - if (client.clientID !== this.lobbyCreatorID) { - this.log.warn(`Only lobby creator can start game`, { - clientID: client.clientID, - creatorID: this.lobbyCreatorID, - gameID: this.id, - }); - return; - } - if (this.isPublic()) { - this.log.warn(`Cannot start public game via WebSocket`, { - gameID: this.id, - }); - return; - } - if (this.hasStarted()) { - this.log.warn(`Cannot start game that has already started`, { - gameID: this.id, - clientID: client.clientID, - }); - return; - } - this.log.info(`Lobby creator starting game via WebSocket`, { - creatorID: client.clientID, - gameID: this.id, - }); - if (this.startsAt) { - this.startsAt = undefined; - } else { - this.setStartsAt( - Date.now() + (this.gameConfig.startDelay ?? 0) * 1000, - ); - } - return; - } - case "toggle_pause": { - // Only lobby creator can pause/resume - if (client.clientID !== this.lobbyCreatorID) { - this.log.warn(`Only lobby creator can toggle pause`, { - clientID: client.clientID, - creatorID: this.lobbyCreatorID, - gameID: this.id, - }); - return; - } - - if (stampedIntent.paused) { - // Pausing: send intent and complete current turn before pause takes effect - this.addIntent(stampedIntent); - this.endTurn(); - this.isPaused = true; - } else { - // Unpausing: clear pause flag before sending intent so next turn can execute - this.isPaused = false; - this.addIntent(stampedIntent); - this.endTurn(); - } - - this.log.info(`Game ${this.isPaused ? "paused" : "resumed"}`, { - clientID: client.clientID, - gameID: this.id, - }); - break; - } - default: { - // Don't process intents while game is paused - if (!this.isPaused) { - this.addIntent(stampedIntent); - } - break; - } + isLobbyCreator: client.clientID === this.lobbyCreatorID, + isAdmin: isAdminRole(client.role), + isAdminBot: false, + }); + if (outcome.status !== 200) { + this.log.warn(`intent rejected`, { + type: clientMsg.intent.type, + clientID: client.clientID, + gameID: this.id, + reason: outcome.error, + }); } break; } diff --git a/src/server/ServerEnv.ts b/src/server/ServerEnv.ts index 79a2df956..dcb8280d8 100644 --- a/src/server/ServerEnv.ts +++ b/src/server/ServerEnv.ts @@ -2,7 +2,7 @@ import { JWK } from "jose"; import { z } from "zod"; import { GameEnv, parseGameEnv } from "../core/configuration/Config"; import { GameID } from "../core/Schemas"; -import { simpleHash } from "../core/Util"; +import { generateID, simpleHash } from "../core/Util"; const JwksSchema = z.object({ keys: z @@ -125,6 +125,19 @@ export class ServerEnv { static workerPortByIndex(index: number): number { return 3001 + index; } + // Generate a game id that hashes to `workerId`, so requests for the game route + // back to this worker. Rejection sampling: each id lands on a uniformly-random + // worker, so the expected number of tries is numWorkers; the cap scales with + // the worker count to keep the failure chance negligible (~e^-100). Returns + // null if none was found (effectively never). + static generateGameIdForWorker(workerId: number): GameID | null { + const maxAttempts = ServerEnv.numWorkers() * 100; + for (let i = 0; i < maxAttempts; i++) { + const id = generateID(); + if (ServerEnv.workerIndex(id) === workerId) return id; + } + return null; + } // Server-only env values static domain(): string { @@ -156,6 +169,15 @@ export class ServerEnv { static apiKey(): string { return process.env.API_KEY ?? ""; } + // Long-lived shared secret for the trusted admin bot HTTP API. + // Undefined when unset, which disables the admin bot API entirely. + static adminBotKey(): string | undefined { + const v = process.env.ADMIN_BOT_API_KEY; + return v && v.length > 0 ? v : undefined; + } + static adminBotHeader(): string { + return "x-admin-bot-key"; + } static allowedFlares(): string[] | undefined { const raw = process.env.ALLOWED_FLARES; if (!raw) return undefined; diff --git a/src/server/Worker.ts b/src/server/Worker.ts index dc5983c6f..fc9bbf48d 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -11,12 +11,12 @@ import { GameEnv } from "../core/configuration/Config"; import { GameType } from "../core/game/Game"; import { ClientMessageSchema, - GameID, PartialGameRecordSchema, ServerErrorMessage, } from "../core/Schemas"; import { generateID, replacer } from "../core/Util"; import { CreateGameInputSchema } from "../core/WorkerSchemas"; +import { registerAdminBotRoutes } from "./AdminBotRoutes"; import { archive, finalizeGameRecord } from "./Archive"; import { Client } from "./Client"; import { GameManager } from "./GameManager"; @@ -171,7 +171,7 @@ export async function startWorker() { .json({ error: "Cannot create public games via this endpoint" }); } - const id = generateGameIdForWorker(); + const id = ServerEnv.generateGameIdForWorker(workerId); if (id === null) { log.warn(`Failed to mint game id on worker ${workerId}`); return res.status(500).json({ error: "Could not allocate game id" }); @@ -219,6 +219,8 @@ export async function startWorker() { baseDir: __dirname, }); + registerAdminBotRoutes({ app, gm, workerId, log }); + app.post("/api/archive_singleplayer_game", async (req, res) => { try { const record = req.body; @@ -553,7 +555,7 @@ async function startMatchmakingPolling(gm: GameManager) { async () => { try { const url = `${ServerEnv.jwtIssuer() + "/matchmaking/checkin"}`; - const gameId = generateGameIdForWorker(); + const gameId = ServerEnv.generateGameIdForWorker(workerId); if (gameId === null) { log.warn(`Failed to generate game ID for worker ${workerId}`); return; @@ -611,21 +613,6 @@ async function startMatchmakingPolling(gm: GameManager) { ); } -// TODO: This is a hack to generate a game ID for the worker. -// It should be replaced with a more robust solution. -function generateGameIdForWorker(): GameID | null { - let attempts = 1000; - while (attempts > 0) { - const gameId = generateID(); - if (workerId === ServerEnv.workerIndex(gameId)) { - return gameId; - } - attempts--; - } - log.warn(`Failed to generate game ID for worker ${workerId}`); - return null; -} - function getClientIp(req: http.IncomingMessage): string { const cfIp = req.headers["cf-connecting-ip"]; if (typeof cfIp === "string" && cfIp) return cfIp; diff --git a/tests/server/AdminBotAuth.test.ts b/tests/server/AdminBotAuth.test.ts new file mode 100644 index 000000000..75213e49c --- /dev/null +++ b/tests/server/AdminBotAuth.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { requireAdminBotKey } from "../../src/server/AdminBotRoutes"; + +function mockRes() { + const res: any = { + statusCode: 200, + body: undefined, + ended: false, + status(code: number) { + this.statusCode = code; + return this; + }, + json(body: unknown) { + this.body = body; + return this; + }, + end() { + this.ended = true; + return this; + }, + }; + return res; +} + +describe("requireAdminBotKey", () => { + const KEY = "super-secret-bot-key"; + + beforeEach(() => { + delete process.env.ADMIN_BOT_API_KEY; + }); + + afterEach(() => { + delete process.env.ADMIN_BOT_API_KEY; + vi.restoreAllMocks(); + }); + + it("returns 404 when the feature is disabled (key unset)", () => { + const res = mockRes(); + const next = vi.fn(); + requireAdminBotKey({ headers: {} } as any, res, next); + expect(res.statusCode).toBe(404); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 401 when the header is missing", () => { + process.env.ADMIN_BOT_API_KEY = KEY; + const res = mockRes(); + const next = vi.fn(); + requireAdminBotKey({ headers: {} } as any, res, next); + expect(res.statusCode).toBe(401); + expect(next).not.toHaveBeenCalled(); + }); + + it("returns 401 when the key is wrong", () => { + process.env.ADMIN_BOT_API_KEY = KEY; + const res = mockRes(); + const next = vi.fn(); + requireAdminBotKey( + { headers: { "x-admin-bot-key": "nope" } } as any, + res, + next, + ); + expect(res.statusCode).toBe(401); + expect(next).not.toHaveBeenCalled(); + }); + + it("calls next when the key matches", () => { + process.env.ADMIN_BOT_API_KEY = KEY; + const res = mockRes(); + const next = vi.fn(); + requireAdminBotKey( + { headers: { "x-admin-bot-key": KEY } } as any, + res, + next, + ); + expect(next).toHaveBeenCalledOnce(); + expect(res.statusCode).toBe(200); + }); +}); diff --git a/tests/server/AdminBotCreateGame.test.ts b/tests/server/AdminBotCreateGame.test.ts new file mode 100644 index 000000000..f47b0f5c2 --- /dev/null +++ b/tests/server/AdminBotCreateGame.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; +import { GameType } from "../../src/core/game/Game"; +import { registerAdminBotRoutes } from "../../src/server/AdminBotRoutes"; + +function mockRes() { + const res: any = { + statusCode: 200, + body: undefined, + status(code: number) { + this.statusCode = code; + return this; + }, + json(body: unknown) { + this.body = body; + return this; + }, + }; + return res; +} + +// Capture the route handlers registered on a fake Express app so we can invoke +// the create_game handler directly. requireAdminBotKey is registered as the +// preceding middleware (tested separately); we call the final handler. +function captureCreateHandler() { + const routes: Record void> = {}; + const app: any = { + post(path: string, ...handlers: ((req: any, res: any) => void)[]) { + routes[path] = handlers[handlers.length - 1]; + }, + }; + const log: any = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + registerAdminBotRoutes({ app, gm: {} as any, workerId: 0, log }); + return routes["/api/adminbot/create_game"]; +} + +describe("admin bot create_game gameType guard", () => { + it("rejects a Singleplayer game with 400", () => { + const handler = captureCreateHandler(); + const res = mockRes(); + handler({ body: { gameType: GameType.Singleplayer } }, res); + expect(res.statusCode).toBe(400); + expect(res.body.error).toMatch(/private games/); + }); + + it("rejects a Public game with 400", () => { + const handler = captureCreateHandler(); + const res = mockRes(); + handler({ body: { gameType: GameType.Public } }, res); + expect(res.statusCode).toBe(400); + expect(res.body.error).toMatch(/private games/); + }); +}); diff --git a/tests/server/AdminBotIntent.test.ts b/tests/server/AdminBotIntent.test.ts new file mode 100644 index 000000000..421955feb --- /dev/null +++ b/tests/server/AdminBotIntent.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GameType } from "../../src/core/game/Game"; +import { ADMIN_BOT_CLIENT_ID } from "../../src/core/Schemas"; +import { GameServer } from "../../src/server/GameServer"; + +describe("GameServer.handleIntent (admin bot)", () => { + let mockLogger: any; + + beforeEach(() => { + vi.useFakeTimers(); + mockLogger = { + child: vi.fn().mockReturnThis(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllTimers(); + }); + + function makeGame(config: Record = {}) { + return new GameServer("test-game", mockLogger, Date.now(), { + gameType: GameType.Private, + ...config, + } as any); + } + + const started = (game: GameServer) => { + (game as any)._hasStarted = true; + }; + + const ADMIN_ACTOR = { + clientID: ADMIN_BOT_CLIENT_ID, + isLobbyCreator: false, + isAdmin: true, + isAdminBot: true, + }; + const apply = (game: GameServer, intent: any) => + game.handleIntent(intent, ADMIN_ACTOR); + + describe("update_game_config", () => { + it("mutates the config", () => { + const game = makeGame({ bots: 100 }); + const result = apply(game, { + type: "update_game_config", + config: { bots: 42 }, + } as any); + expect(result.status).toBe(200); + expect((game as any).gameConfig.bots).toBe(42); + }); + + it("rejects a public game with 403", () => { + const game = makeGame({ gameType: GameType.Public }); + expect( + apply(game, { + type: "update_game_config", + config: { bots: 1 }, + } as any).status, + ).toBe(403); + }); + + it("rejects promoting a game to public with 400", () => { + const game = makeGame(); + expect( + apply(game, { + type: "update_game_config", + config: { gameType: GameType.Public }, + } as any).status, + ).toBe(400); + }); + + it("rejects updates after the game has started with 409", () => { + const game = makeGame(); + started(game); + expect( + apply(game, { + type: "update_game_config", + config: { bots: 1 }, + } as any).status, + ).toBe(409); + }); + }); + + describe("toggle_game_start_timer", () => { + it("sets then clears startsAt", () => { + const game = makeGame({ startDelay: 0 }); + expect((game as any).startsAt).toBeUndefined(); + + expect( + apply(game, { type: "toggle_game_start_timer" } as any).status, + ).toBe(200); + expect((game as any).startsAt).toBeDefined(); + + expect( + apply(game, { type: "toggle_game_start_timer" } as any).status, + ).toBe(200); + expect((game as any).startsAt).toBeUndefined(); + }); + + it("rejects after the game has started with 409", () => { + const game = makeGame(); + started(game); + expect( + apply(game, { type: "toggle_game_start_timer" } as any).status, + ).toBe(409); + }); + }); + + describe("kick_player", () => { + it("routes to kickClient", () => { + const game = makeGame(); + const spy = vi.spyOn(game, "kickClient"); + const result = apply(game, { + type: "kick_player", + target: "abcdABCD", + } as any); + expect(result.status).toBe(200); + expect(spy).toHaveBeenCalledWith("abcdABCD", expect.any(String)); + }); + + it("rejects a public game with 403", () => { + const game = makeGame({ gameType: GameType.Public }); + expect( + apply(game, { + type: "kick_player", + target: "abcdABCD", + } as any).status, + ).toBe(403); + }); + }); + + describe("toggle_pause", () => { + it("rejects when the game has not started with 409", () => { + const game = makeGame(); + expect( + apply(game, { type: "toggle_pause", paused: true } as any).status, + ).toBe(409); + }); + + it("pauses and resumes a started game", () => { + const game = makeGame(); + started(game); + + expect( + apply(game, { type: "toggle_pause", paused: true } as any).status, + ).toBe(200); + expect((game as any).isPaused).toBe(true); + + expect( + apply(game, { type: "toggle_pause", paused: false } as any).status, + ).toBe(200); + expect((game as any).isPaused).toBe(false); + }); + + it("records the pause intent stamped with the placeholder clientID", () => { + const game = makeGame(); + started(game); + apply(game, { type: "toggle_pause", paused: true } as any); + + const intents = (game as any).turns.flatMap((t: any) => t.intents); + const pause = intents.find((i: any) => i.type === "toggle_pause"); + expect(pause).toBeDefined(); + expect(pause.clientID).toBe(ADMIN_BOT_CLIENT_ID); + }); + }); + + describe("rejected intents", () => { + it("rejects a gameplay intent with 400", () => { + const game = makeGame(); + expect(apply(game, { type: "spawn", x: 1, y: 1 } as any).status).toBe( + 400, + ); + }); + + it("rejects mark_disconnected with 400", () => { + const game = makeGame(); + expect( + apply(game, { + type: "mark_disconnected", + isDisconnected: true, + } as any).status, + ).toBe(400); + }); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 174b089cb..393dc2ca7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -52,22 +52,25 @@ function serveProprietaryDir( }; } -// Dev-only stand-in for the nginx random create-game routing. Forwards -// POST /api/create_game to a randomly chosen worker port so the worker can -// mint a self-owned id. Runs as direct middleware (before vite's /api proxy). +// Dev-only stand-in for the nginx random-worker routing (the openfront_workers +// upstream). Forwards these prefix-less POSTs to a randomly chosen worker port +// so the worker can mint a self-owned id. Runs as direct middleware (before +// vite's /api proxy). +const RANDOM_WORKER_PATHS = ["/api/create_game", "/api/adminbot/create_game"]; function randomWorkerCreateProxy(numWorkers: number): Plugin { return { name: "random-worker-create-proxy", configureServer(server) { server.middlewares.use((req, res, next) => { if (req.method !== "POST") return next(); - if ((req.url ?? "").split("?")[0] !== "/api/create_game") return next(); + const path = (req.url ?? "").split("?")[0]; + if (!RANDOM_WORKER_PATHS.includes(path)) return next(); const port = 3001 + Math.floor(Math.random() * numWorkers); const proxyReq = http.request( { host: "localhost", port, - path: "/api/create_game", + path, method: "POST", headers: req.headers, },