mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-17 20:19:12 +00:00
Add admin bot HTTP API for managing private games (#4388)
## What A trusted, server-side HTTP API so a bot authenticated with a shared secret can **create private games, change their settings, start them, kick players, and pause/resume** — without opening a WebSocket or joining as a player. Two endpoints under `/api/adminbot/`, reaching the owning worker via the existing `/wN/` nginx routing. They reuse the existing Zod schemas and `GameServer` methods, mirroring the WebSocket intent flow rather than inventing a new wire protocol. | Endpoint | Purpose | | --- | --- | | `POST /api/adminbot/create_game` | Create a private game; the worker mints a self-owned id and returns it (body: `GameConfigSchema.partial()`) | | `POST /api/adminbot/game/:id/intent` | Send a lobby-management intent (body: base `IntentSchema`) | ## How it works - **Auth:** `ADMIN_BOT_API_KEY` env var via the `x-admin-bot-key` header (timing-safe compare). The whole API is **disabled — 404 — when the var is unset**, so non-configured environments expose nothing. It's distinct from the per-instance `ADMIN_TOKEN`, which an external bot can't know. - **`GameServer.handleIntent`** is the unified intent dispatch for both the WebSocket `case "intent"` path and the admin-bot HTTP API. An `IntentActor` carries identity + authority (per-connection lobby-creator/role checks for the WS path; admin authority for the bot). It honors `update_game_config`, `toggle_game_start_timer`, `kick_player`, and `toggle_pause` — **on private games only** (`isPublic()` → 403). Gameplay intents and `mark_disconnected` are rejected (400). - **Private games only.** `create_game` rejects any `gameType` other than `Private` (Public *and* Singleplayer → 400); an omitted `gameType` defaults to `Private`. - **The bot is never a player.** It sends no `clientID`; the server stamps a placeholder `ADMIN_BOT_CLIENT_ID = "ADMINBOT"` (collision-proof — contains `I`/`O`, which `generateID()` never emits). A gameplay intent stamped with it would resolve to no player, so puppeteering is structurally impossible on top of the explicit 400. - **Determinism unchanged:** the only intent that reaches the sim is `toggle_pause`, via the same `addIntent` → turn queue → `ServerTurnMessage` path the WS uses. ## Notable details for review - **`hostCheats` is assigned unconditionally — on purpose.** `updateGameConfig` sets `this.gameConfig.hostCheats = gameConfig.hostCheats` unconditionally, unlike its sibling fields (which are guarded on `!== undefined`). The WS host clears cheats by re-sending the *full* config with `hostCheats: undefined`, so here `undefined` must mean "clear", not "leave unchanged". **Caveat for the admin bot**, which is a *partial*-update client: a partial `update_game_config` that omits `hostCheats` will clear it — the bot should send `hostCheats` explicitly (or a full config) when it wants to keep a previously-set value. - **Deploy wiring:** `ADMIN_BOT_API_KEY` is piped through the deploy steps' `env:` in `deploy.yml`/`release.yml` → `deploy.sh` heredoc → container via `update.sh`'s `--env-file`. The remaining manual step is creating the GitHub secret itself. ## Tests 19 new tests: - `GameServer.handleIntent` admin-bot behavior (per-intent, private-only, post-start guards, placeholder clientID, rejected gameplay/`mark_disconnected` intents). - `create_game` gameType guard (Public and Singleplayer both rejected). - `requireAdminBotKey` middleware (404 disabled / 401 missing / 401 wrong / pass). tsc + eslint clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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());
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user