From 9a434fd03a32e3262473799feed06f9763bf4d7a Mon Sep 17 00:00:00 2001 From: evanpelle Date: Fri, 5 Sep 2025 10:11:16 -0700 Subject: [PATCH] remove gatekeeper submodule because it is not AGPL compatible (#2012) ## Description: Gatekeeper is a private submodule was planned to be used for security, bot detection etc, but actually just a no op. Since gatekeeper is private, it's not AGPL compatible. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/server/GameServer.ts | 197 ++++++------ src/server/Gatekeeper.ts | 160 --------- src/server/Master.ts | 86 +++-- src/server/Worker.ts | 678 +++++++++++++++++++-------------------- src/server/gatekeeper | 1 - 5 files changed, 456 insertions(+), 666 deletions(-) delete mode 100644 src/server/Gatekeeper.ts delete mode 160000 src/server/gatekeeper diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index b16dae9b3..0ac2621f5 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -24,7 +24,6 @@ import { GameEnv, ServerConfig } from "../core/configuration/Config"; import { GameType } from "../core/game/Game"; import { archive } from "./Archive"; import { Client } from "./Client"; -import { gatekeeper } from "./Gatekeeper"; export enum GamePhase { Lobby = "LOBBY", Active = "ACTIVE", @@ -198,125 +197,119 @@ export class GameServer { this.allClients.set(client.clientID, client); client.ws.removeAllListeners("message"); - client.ws.on( - "message", - gatekeeper.wsHandler(client.ip, async (message: string) => { - try { - const parsed = ClientMessageSchema.safeParse(JSON.parse(message)); - if (!parsed.success) { - const error = z.prettifyError(parsed.error); - this.log.error("Failed to parse client message", error, { - clientID: client.clientID, - }); - client.ws.send( - JSON.stringify({ - type: "error", - error, - message, - } satisfies ServerErrorMessage), - ); - client.ws.close(1002, "ClientMessageSchema"); - return; - } - const clientMsg = parsed.data; - switch (clientMsg.type) { - case "intent": { - if (clientMsg.intent.clientID !== client.clientID) { + client.ws.on("message", async (message: string) => { + try { + const parsed = ClientMessageSchema.safeParse(JSON.parse(message)); + if (!parsed.success) { + const error = z.prettifyError(parsed.error); + this.log.error("Failed to parse client message", error, { + clientID: client.clientID, + }); + client.ws.send( + JSON.stringify({ + type: "error", + error, + message, + } satisfies ServerErrorMessage), + ); + client.ws.close(1002, "ClientMessageSchema"); + return; + } + const clientMsg = parsed.data; + switch (clientMsg.type) { + case "intent": { + if (clientMsg.intent.clientID !== client.clientID) { + this.log.warn( + `client id mismatch, client: ${client.clientID}, intent: ${clientMsg.intent.clientID}`, + ); + return; + } + switch (clientMsg.intent.type) { + case "mark_disconnected": { this.log.warn( - `client id mismatch, client: ${client.clientID}, intent: ${clientMsg.intent.clientID}`, + `Should not receive mark_disconnected intent from client`, ); return; } - switch (clientMsg.intent.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 authenticatedClientID = client.clientID; + // Handle kick_player intent via WebSocket + case "kick_player": { + const authenticatedClientID = client.clientID; - // Check if the authenticated client is the lobby creator - if (authenticatedClientID !== this.LobbyCreatorID) { - this.log.warn(`Only lobby creator can kick players`, { - clientID: authenticatedClientID, - creatorID: this.LobbyCreatorID, - target: clientMsg.intent.target, - gameID: this.id, - }); - return; - } - - // Don't allow lobby creator to kick themselves - if (authenticatedClientID === clientMsg.intent.target) { - this.log.warn(`Cannot kick yourself`, { - clientID: authenticatedClientID, - }); - return; - } - - // Log and execute the kick - this.log.info(`Lobby creator initiated kick of player`, { - creatorID: authenticatedClientID, + // Check if the authenticated client is the lobby creator + if (authenticatedClientID !== this.LobbyCreatorID) { + this.log.warn(`Only lobby creator can kick players`, { + clientID: authenticatedClientID, + creatorID: this.LobbyCreatorID, target: clientMsg.intent.target, gameID: this.id, - kickMethod: "websocket", }); - - this.kickClient(clientMsg.intent.target); return; } - default: { - this.addIntent(clientMsg.intent); - break; + + // Don't allow lobby creator to kick themselves + if (authenticatedClientID === clientMsg.intent.target) { + this.log.warn(`Cannot kick yourself`, { + clientID: authenticatedClientID, + }); + return; } - } - break; - } - case "ping": { - this.lastPingUpdate = Date.now(); - client.lastPing = Date.now(); - break; - } - case "hash": { - client.hashes.set(clientMsg.turnNumber, clientMsg.hash); - break; - } - case "winner": { - if ( - this.outOfSyncClients.has(client.clientID) || - this.kickedClients.has(client.clientID) || - this.winner !== null - ) { + + // Log and execute the kick + this.log.info(`Lobby creator initiated kick of player`, { + creatorID: authenticatedClientID, + target: clientMsg.intent.target, + gameID: this.id, + kickMethod: "websocket", + }); + + this.kickClient(clientMsg.intent.target); return; } - this.winner = clientMsg; - this.archiveGame(); - break; - } - default: { - this.log.warn( - `Unknown message type: ${(clientMsg as any).type}`, - { - clientID: client.clientID, - }, - ); - break; + default: { + this.addIntent(clientMsg.intent); + break; + } } + break; } - } catch (error) { - this.log.info( - `error handline websocket request in game server: ${error}`, - { + case "ping": { + this.lastPingUpdate = Date.now(); + client.lastPing = Date.now(); + break; + } + case "hash": { + client.hashes.set(clientMsg.turnNumber, clientMsg.hash); + break; + } + case "winner": { + if ( + this.outOfSyncClients.has(client.clientID) || + this.kickedClients.has(client.clientID) || + this.winner !== null + ) { + return; + } + this.winner = clientMsg; + this.archiveGame(); + break; + } + default: { + this.log.warn(`Unknown message type: ${(clientMsg as any).type}`, { clientID: client.clientID, - }, - ); + }); + break; + } } - }), - ); + } catch (error) { + this.log.info( + `error handline websocket request in game server: ${error}`, + { + clientID: client.clientID, + }, + ); + } + }); client.ws.on("close", () => { this.log.info("client disconnected", { clientID: client.clientID, diff --git a/src/server/Gatekeeper.ts b/src/server/Gatekeeper.ts deleted file mode 100644 index ee47cbf50..000000000 --- a/src/server/Gatekeeper.ts +++ /dev/null @@ -1,160 +0,0 @@ -// src/server/Security.ts -import { NextFunction, Request, Response } from "express"; -import fs from "fs"; -import http from "http"; -import path from "path"; -import { fileURLToPath } from "url"; - -export enum LimiterType { - Get = "get", - Post = "post", - Put = "put", - WebSocket = "websocket", -} - -export interface Gatekeeper { - // The wrapper for request handlers with optional rate limiting - httpHandler: ( - limiterType: LimiterType, - fn: (req: Request, res: Response, next: NextFunction) => Promise, - ) => (req: Request, res: Response, next: NextFunction) => Promise; - - // The wrapper for WebSocket message handlers with rate limiting - wsHandler: ( - req: http.IncomingMessage | string, - fn: (message: string) => Promise, - ) => (message: string) => Promise; -} - -let gk: Gatekeeper | null = null; - -async function getGatekeeperCached(): Promise { - if (gk !== null) { - return gk; - } - return getGatekeeper().then((g) => { - gk = g; - return gk; - }); -} - -// Function to get the appropriate security middleware implementation -async function getGatekeeper(): Promise { - try { - // Get the current file's directory - const __filename = fileURLToPath(import.meta.url); - const __dirname = path.dirname(__filename); - - try { - // Check if the file exists before attempting to import it - const realMiddlewarePath = path.resolve( - __dirname, - "./gatekeeper/RealGatekeeper.js", - ); - const tsMiddlewarePath = path.resolve( - __dirname, - "./gatekeeper/RealGatekeeper.ts", - ); - - if ( - !fs.existsSync(realMiddlewarePath) && - !fs.existsSync(tsMiddlewarePath) - ) { - console.log("RealGatekeeper file not found, using NoOpGatekeeper"); - return new NoOpGatekeeper(); - } - - // Use dynamic import for ES modules - // Using a type assertion to avoid TypeScript errors for optional modules - const module = await import( - "./gatekeeper/RealGatekeeper.js" as string - ).catch(() => import("./gatekeeper/RealGatekeeper.js" as string)); - - if (!module || !module.RealGatekeeper) { - console.log( - "RealGatekeeper class not found in module, using NoOpGatekeeper", - ); - return new NoOpGatekeeper(); - } - - console.log("Successfully loaded real gatekeeper"); - return new module.RealGatekeeper(); - } catch (error) { - console.log("Failed to load real gatekeeper:", error); - return new NoOpGatekeeper(); - } - } catch (e) { - // Fall back to no-op if real implementation isn't available - console.log("using no-op gatekeeper", e); - return new NoOpGatekeeper(); - } -} - -export class GatekeeperWrapper implements Gatekeeper { - constructor(private getGK: () => Promise) {} - - httpHandler( - limiterType: LimiterType, - fn: (req: Request, res: Response, next: NextFunction) => Promise, - ) { - return async (req: Request, res: Response, next: NextFunction) => { - try { - const gk = await this.getGK(); - const handler = gk.httpHandler(limiterType, fn); - return handler(req, res, next); - } catch (error) { - next(error); - } - }; - } - - // Corrected implementation for WebSocket handler wrapper - wsHandler( - req: http.IncomingMessage | string, - fn: (message: string) => Promise, - ) { - return async (message: string) => { - try { - const gk = await this.getGK(); - const handler = gk.wsHandler(req, fn); - return handler(message); - } catch (error) { - console.error("WebSocket handler error:", error); - } - }; - } -} - -export class NoOpGatekeeper implements Gatekeeper { - // Simple pass-through with no rate limiting - httpHandler( - limiterType: LimiterType, - fn: (req: Request, res: Response, next: NextFunction) => Promise, - ) { - return async (req: Request, res: Response, next: NextFunction) => { - try { - await fn(req, res, next); - } catch (error) { - next(error); - } - }; - } - - // Corrected implementation for WebSocket handler wrapper - wsHandler( - req: http.IncomingMessage | string, - fn: (message: string) => Promise, - ) { - return async (message: string) => { - try { - await fn(message); - } catch (error) { - console.error("WebSocket handler error:", error); - } - }; - } -} - -export const gatekeeper: Gatekeeper = new GatekeeperWrapper(() => - getGatekeeperCached(), -); diff --git a/src/server/Master.ts b/src/server/Master.ts index 2b835ff85..993a4cec5 100644 --- a/src/server/Master.ts +++ b/src/server/Master.ts @@ -7,7 +7,6 @@ import { fileURLToPath } from "url"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { GameInfo, ID } from "../core/Schemas"; import { generateID } from "../core/Util"; -import { gatekeeper, LimiterType } from "./Gatekeeper"; import { logger } from "./Logger"; import { MapPlaylist } from "./MapPlaylist"; @@ -142,62 +141,53 @@ export async function startMaster() { }); } -app.get( - "/api/env", - gatekeeper.httpHandler(LimiterType.Get, async (req, res) => { - const envConfig = { - game_env: process.env.GAME_ENV, - }; - if (!envConfig.game_env) return res.sendStatus(500); - res.json(envConfig); - }), -); +app.get("/api/env", async (req, res) => { + const envConfig = { + game_env: process.env.GAME_ENV, + }; + if (!envConfig.game_env) return res.sendStatus(500); + res.json(envConfig); +}); // Add lobbies endpoint to list public games for this worker -app.get( - "/api/public_lobbies", - gatekeeper.httpHandler(LimiterType.Get, async (req, res) => { - res.send(publicLobbiesJsonStr); - }), -); +app.get("/api/public_lobbies", async (req, res) => { + res.send(publicLobbiesJsonStr); +}); -app.post( - "/api/kick_player/:gameID/:clientID", - gatekeeper.httpHandler(LimiterType.Post, async (req, res) => { - if (req.headers[config.adminHeader()] !== config.adminToken()) { - res.status(401).send("Unauthorized"); - return; - } +app.post("/api/kick_player/:gameID/:clientID", async (req, res) => { + if (req.headers[config.adminHeader()] !== config.adminToken()) { + res.status(401).send("Unauthorized"); + return; + } - const { gameID, clientID } = req.params; + const { gameID, clientID } = req.params; - if (!ID.safeParse(gameID).success || !ID.safeParse(clientID).success) { - res.sendStatus(400); - return; - } + if (!ID.safeParse(gameID).success || !ID.safeParse(clientID).success) { + res.sendStatus(400); + return; + } - try { - const response = await fetch( - `http://localhost:${config.workerPort(gameID)}/api/kick_player/${gameID}/${clientID}`, - { - method: "POST", - headers: { - [config.adminHeader()]: config.adminToken(), - }, + try { + const response = await fetch( + `http://localhost:${config.workerPort(gameID)}/api/kick_player/${gameID}/${clientID}`, + { + method: "POST", + headers: { + [config.adminHeader()]: config.adminToken(), }, - ); + }, + ); - if (!response.ok) { - throw new Error(`Failed to kick player: ${response.statusText}`); - } - - res.status(200).send("Player kicked successfully"); - } catch (error) { - log.error(`Error kicking player from game ${gameID}:`, error); - res.status(500).send("Failed to kick player"); + if (!response.ok) { + throw new Error(`Failed to kick player: ${response.statusText}`); } - }), -); + + res.status(200).send("Player kicked successfully"); + } catch (error) { + log.error(`Error kicking player from game ${gameID}:`, error); + res.status(500).send("Failed to kick player"); + } +}); async function fetchLobbies(): Promise { const fetchPromises: Promise[] = []; diff --git a/src/server/Worker.ts b/src/server/Worker.ts index d16da80fb..ae6e413e7 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -20,7 +20,6 @@ import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas"; import { archive, readGameRecord } from "./Archive"; import { Client } from "./Client"; import { GameManager } from "./GameManager"; -import { gatekeeper, LimiterType } from "./Gatekeeper"; import { getUserMe, verifyClientToken } from "./jwt"; import { logger } from "./Logger"; @@ -91,394 +90,363 @@ export async function startWorker() { }), ); - app.post( - "/api/create_game/:id", - gatekeeper.httpHandler(LimiterType.Post, async (req, res) => { - const id = req.params.id; - const creatorClientID = (() => { - if (typeof req.query.creatorClientID !== "string") return undefined; + app.post("/api/create_game/:id", async (req, res) => { + const id = req.params.id; + const creatorClientID = (() => { + if (typeof req.query.creatorClientID !== "string") return undefined; - const trimmed = req.query.creatorClientID.trim(); - return ID.safeParse(trimmed).success ? trimmed : undefined; - })(); + const trimmed = req.query.creatorClientID.trim(); + return ID.safeParse(trimmed).success ? trimmed : undefined; + })(); - if (!id) { - log.warn(`cannot create game, id not found`); - return res.status(400).json({ error: "Game ID is required" }); - } - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const clientIP = req.ip || req.socket.remoteAddress || "unknown"; - const result = CreateGameInputSchema.safeParse(req.body); - if (!result.success) { - const error = z.prettifyError(result.error); - return res.status(400).json({ error }); - } + if (!id) { + log.warn(`cannot create game, id not found`); + return res.status(400).json({ error: "Game ID is required" }); + } + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const clientIP = req.ip || req.socket.remoteAddress || "unknown"; + const result = CreateGameInputSchema.safeParse(req.body); + if (!result.success) { + const error = z.prettifyError(result.error); + return res.status(400).json({ error }); + } - const gc = result.data; - if ( - gc?.gameType === GameType.Public && - req.headers[config.adminHeader()] !== config.adminToken() - ) { - log.warn( - `cannot create public game ${id}, ip ${ipAnonymize(clientIP)} incorrect admin token`, - ); - return res.status(401).send("Unauthorized"); - } - - // Double-check this worker should host this game - const expectedWorkerId = config.workerIndex(id); - if (expectedWorkerId !== workerId) { - log.warn( - `This game ${id} should be on worker ${expectedWorkerId}, but this is worker ${workerId}`, - ); - return res.status(400).json({ error: "Worker, game id mismatch" }); - } - - // Pass creatorClientID to createGame - const game = gm.createGame(id, gc, creatorClientID); - - log.info( - `Worker ${workerId}: IP ${ipAnonymize(clientIP)} creating ${game.isPublic() ? "Public" : "Private"}${gc?.gameMode ? ` ${gc.gameMode}` : ""} game with id ${id}${creatorClientID ? `, creator: ${creatorClientID}` : ""}`, + const gc = result.data; + if ( + gc?.gameType === GameType.Public && + req.headers[config.adminHeader()] !== config.adminToken() + ) { + log.warn( + `cannot create public game ${id}, ip ${ipAnonymize(clientIP)} incorrect admin token`, ); - res.json(game.gameInfo()); - }), - ); + return res.status(401).send("Unauthorized"); + } + + // Double-check this worker should host this game + const expectedWorkerId = config.workerIndex(id); + if (expectedWorkerId !== workerId) { + log.warn( + `This game ${id} should be on worker ${expectedWorkerId}, but this is worker ${workerId}`, + ); + return res.status(400).json({ error: "Worker, game id mismatch" }); + } + + // Pass creatorClientID to createGame + const game = gm.createGame(id, gc, creatorClientID); + + log.info( + `Worker ${workerId}: IP ${ipAnonymize(clientIP)} creating ${game.isPublic() ? "Public" : "Private"}${gc?.gameMode ? ` ${gc.gameMode}` : ""} game with id ${id}${creatorClientID ? `, creator: ${creatorClientID}` : ""}`, + ); + res.json(game.gameInfo()); + }); // Add other endpoints from your original server - app.post( - "/api/start_game/:id", - gatekeeper.httpHandler(LimiterType.Post, async (req, res) => { - log.info(`starting private lobby with id ${req.params.id}`); - const game = gm.game(req.params.id); - if (!game) { - return; - } - if (game.isPublic()) { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const clientIP = req.ip || req.socket.remoteAddress || "unknown"; - log.info( - `cannot start public game ${game.id}, game is public, ip: ${ipAnonymize(clientIP)}`, - ); - return; - } - game.start(); - res.status(200).json({ success: true }); - }), - ); + app.post("/api/start_game/:id", async (req, res) => { + log.info(`starting private lobby with id ${req.params.id}`); + const game = gm.game(req.params.id); + if (!game) { + return; + } + if (game.isPublic()) { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const clientIP = req.ip || req.socket.remoteAddress || "unknown"; + log.info( + `cannot start public game ${game.id}, game is public, ip: ${ipAnonymize(clientIP)}`, + ); + return; + } + game.start(); + res.status(200).json({ success: true }); + }); - app.put( - "/api/game/:id", - gatekeeper.httpHandler(LimiterType.Put, async (req, res) => { - const result = GameInputSchema.safeParse(req.body); - if (!result.success) { - const error = z.prettifyError(result.error); - return res.status(400).json({ error }); - } - const config = result.data; - // TODO: only update public game if from local host - const lobbyID = req.params.id; - if (config.gameType === GameType.Public) { - log.info(`cannot update game ${lobbyID} to public`); - return res.status(400).json({ error: "Cannot update public game" }); - } - const game = gm.game(lobbyID); - if (!game) { - return res.status(400).json({ error: "Game not found" }); - } - if (game.isPublic()) { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const clientIP = req.ip || req.socket.remoteAddress || "unknown"; - log.warn( - `cannot update public game ${game.id}, ip: ${ipAnonymize(clientIP)}`, - ); - return res.status(400).json({ error: "Cannot update public game" }); - } - if (game.hasStarted()) { - log.warn(`cannot update game ${game.id} after it has started`); - return res - .status(400) - .json({ error: "Cannot update game after it has started" }); - } - game.updateGameConfig(config); - res.status(200).json({ success: true }); - }), - ); + app.put("/api/game/:id", async (req, res) => { + const result = GameInputSchema.safeParse(req.body); + if (!result.success) { + const error = z.prettifyError(result.error); + return res.status(400).json({ error }); + } + const config = result.data; + // TODO: only update public game if from local host + const lobbyID = req.params.id; + if (config.gameType === GameType.Public) { + log.info(`cannot update game ${lobbyID} to public`); + return res.status(400).json({ error: "Cannot update public game" }); + } + const game = gm.game(lobbyID); + if (!game) { + return res.status(400).json({ error: "Game not found" }); + } + if (game.isPublic()) { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const clientIP = req.ip || req.socket.remoteAddress || "unknown"; + log.warn( + `cannot update public game ${game.id}, ip: ${ipAnonymize(clientIP)}`, + ); + return res.status(400).json({ error: "Cannot update public game" }); + } + if (game.hasStarted()) { + log.warn(`cannot update game ${game.id} after it has started`); + return res + .status(400) + .json({ error: "Cannot update game after it has started" }); + } + game.updateGameConfig(config); + res.status(200).json({ success: true }); + }); - app.get( - "/api/game/:id/exists", - gatekeeper.httpHandler(LimiterType.Get, async (req, res) => { - const lobbyId = req.params.id; - res.json({ - exists: gm.game(lobbyId) !== null, + app.get("/api/game/:id/exists", async (req, res) => { + const lobbyId = req.params.id; + res.json({ + exists: gm.game(lobbyId) !== null, + }); + }); + + app.get("/api/game/:id", async (req, res) => { + const game = gm.game(req.params.id); + if (game === null) { + log.info(`lobby ${req.params.id} not found`); + return res.status(404).json({ error: "Game not found" }); + } + res.json(game.gameInfo()); + }); + + app.get("/api/archived_game/:id", async (req, res) => { + const gameRecord = await readGameRecord(req.params.id); + + if (!gameRecord) { + return res.status(404).json({ + success: false, + error: "Game not found", + exists: false, }); - }), - ); + } - app.get( - "/api/game/:id", - gatekeeper.httpHandler(LimiterType.Get, async (req, res) => { - const game = gm.game(req.params.id); - if (game === null) { - log.info(`lobby ${req.params.id} not found`); - return res.status(404).json({ error: "Game not found" }); - } - res.json(game.gameInfo()); - }), - ); - - app.get( - "/api/archived_game/:id", - gatekeeper.httpHandler(LimiterType.Get, async (req, res) => { - const gameRecord = await readGameRecord(req.params.id); - - if (!gameRecord) { - return res.status(404).json({ - success: false, - error: "Game not found", - exists: false, - }); - } - - if ( - config.env() !== GameEnv.Dev && - gameRecord.gitCommit !== config.gitCommit() - ) { - log.warn( - `git commit mismatch for game ${req.params.id}, expected ${config.gitCommit()}, got ${gameRecord.gitCommit}`, - ); - return res.status(409).json({ - success: false, - error: "Version mismatch", - exists: true, - details: { - expectedCommit: config.gitCommit(), - actualCommit: gameRecord.gitCommit, - }, - }); - } - - return res.status(200).json({ - success: true, + if ( + config.env() !== GameEnv.Dev && + gameRecord.gitCommit !== config.gitCommit() + ) { + log.warn( + `git commit mismatch for game ${req.params.id}, expected ${config.gitCommit()}, got ${gameRecord.gitCommit}`, + ); + return res.status(409).json({ + success: false, + error: "Version mismatch", exists: true, - gameRecord: gameRecord, + details: { + expectedCommit: config.gitCommit(), + actualCommit: gameRecord.gitCommit, + }, }); - }), - ); + } - app.post( - "/api/archive_singleplayer_game", - gatekeeper.httpHandler(LimiterType.Post, async (req, res) => { - const result = GameRecordSchema.safeParse(req.body); - if (!result.success) { - const error = z.prettifyError(result.error); - log.info(error); - return res.status(400).json({ error }); - } + return res.status(200).json({ + success: true, + exists: true, + gameRecord: gameRecord, + }); + }); - const gameRecord: GameRecord = result.data; - archive(gameRecord); - res.json({ - success: true, - }); - }), - ); + app.post("/api/archive_singleplayer_game", async (req, res) => { + const result = GameRecordSchema.safeParse(req.body); + if (!result.success) { + const error = z.prettifyError(result.error); + log.info(error); + return res.status(400).json({ error }); + } - app.post( - "/api/kick_player/:gameID/:clientID", - gatekeeper.httpHandler(LimiterType.Post, async (req, res) => { - if (req.headers[config.adminHeader()] !== config.adminToken()) { - res.status(401).send("Unauthorized"); - return; - } + const gameRecord: GameRecord = result.data; + archive(gameRecord); + res.json({ + success: true, + }); + }); - const { gameID, clientID } = req.params; + app.post("/api/kick_player/:gameID/:clientID", async (req, res) => { + if (req.headers[config.adminHeader()] !== config.adminToken()) { + res.status(401).send("Unauthorized"); + return; + } - const game = gm.game(gameID); - if (!game) { - res.status(404).send("Game not found"); - return; - } + const { gameID, clientID } = req.params; - game.kickClient(clientID); - res.status(200).send("Player kicked successfully"); - }), - ); + const game = gm.game(gameID); + if (!game) { + res.status(404).send("Game not found"); + return; + } + + game.kickClient(clientID); + res.status(200).send("Player kicked successfully"); + }); // WebSocket handling wss.on("connection", (ws: WebSocket, req) => { - ws.on( - "message", - gatekeeper.wsHandler(req, async (message: string) => { - const forwarded = req.headers["x-forwarded-for"]; - const ip = Array.isArray(forwarded) - ? forwarded[0] - : // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - forwarded || req.socket.remoteAddress || "unknown"; + ws.on("message", async (message: string) => { + const forwarded = req.headers["x-forwarded-for"]; + const ip = Array.isArray(forwarded) + ? forwarded[0] + : // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + forwarded || req.socket.remoteAddress || "unknown"; - try { - // Parse and handle client messages - const parsed = ClientMessageSchema.safeParse( - JSON.parse(message.toString()), + try { + // Parse and handle client messages + const parsed = ClientMessageSchema.safeParse( + JSON.parse(message.toString()), + ); + if (!parsed.success) { + const error = z.prettifyError(parsed.error); + log.warn("Error parsing client message", error); + ws.send( + JSON.stringify({ + type: "error", + error: error.toString(), + } satisfies ServerErrorMessage), ); - if (!parsed.success) { - const error = z.prettifyError(parsed.error); - log.warn("Error parsing client message", error); - ws.send( - JSON.stringify({ - type: "error", - error: error.toString(), - } satisfies ServerErrorMessage), - ); - ws.close(1002, "ClientJoinMessageSchema"); - return; - } - const clientMsg = parsed.data; + ws.close(1002, "ClientJoinMessageSchema"); + return; + } + const clientMsg = parsed.data; - if (clientMsg.type === "ping") { - // Ignore ping - return; - } else if (clientMsg.type !== "join") { - log.warn( - `Invalid message before join: ${JSON.stringify(clientMsg)}`, - ); - return; - } + if (clientMsg.type === "ping") { + // Ignore ping + return; + } else if (clientMsg.type !== "join") { + log.warn(`Invalid message before join: ${JSON.stringify(clientMsg)}`); + return; + } - // Verify this worker should handle this game - const expectedWorkerId = config.workerIndex(clientMsg.gameID); - if (expectedWorkerId !== workerId) { - log.warn( - `Worker mismatch: Game ${clientMsg.gameID} should be on worker ${expectedWorkerId}, but this is worker ${workerId}`, - ); - return; - } + // Verify this worker should handle this game + const expectedWorkerId = config.workerIndex(clientMsg.gameID); + if (expectedWorkerId !== workerId) { + log.warn( + `Worker mismatch: Game ${clientMsg.gameID} should be on worker ${expectedWorkerId}, but this is worker ${workerId}`, + ); + return; + } - // Verify token signature - const result = await verifyClientToken(clientMsg.token, config); - if (result === false) { - log.warn("Unauthorized: Invalid token"); + // Verify token signature + const result = await verifyClientToken(clientMsg.token, config); + if (result === false) { + log.warn("Unauthorized: Invalid token"); + ws.close(1002, "Unauthorized"); + return; + } + const { persistentId, claims } = result; + + let roles: string[] | undefined; + let flares: string[] | undefined; + + const allowedFlares = config.allowedFlares(); + if (claims === null) { + if (allowedFlares !== undefined) { + log.warn("Unauthorized: Anonymous user attempted to join game"); ws.close(1002, "Unauthorized"); return; } - const { persistentId, claims } = result; + } else { + // Verify token and get player permissions + const result = await getUserMe(clientMsg.token, config); + if (result === false) { + log.warn("Unauthorized: Invalid session"); + ws.close(1002, "Unauthorized"); + return; + } + roles = result.player.roles; + flares = result.player.flares; - let roles: string[] | undefined; - let flares: string[] | undefined; - - const allowedFlares = config.allowedFlares(); - if (claims === null) { - if (allowedFlares !== undefined) { - log.warn("Unauthorized: Anonymous user attempted to join game"); - ws.close(1002, "Unauthorized"); + if (allowedFlares !== undefined) { + const allowed = + allowedFlares.length === 0 || + allowedFlares.some((f) => flares?.includes(f)); + if (!allowed) { + log.warn( + "Forbidden: player without an allowed flare attempted to join game", + ); + ws.close(1002, "Forbidden"); return; } - } else { - // Verify token and get player permissions - const result = await getUserMe(clientMsg.token, config); - if (result === false) { - log.warn("Unauthorized: Invalid session"); - ws.close(1002, "Unauthorized"); - return; - } - roles = result.player.roles; - flares = result.player.flares; - - if (allowedFlares !== undefined) { - const allowed = - allowedFlares.length === 0 || - allowedFlares.some((f) => flares?.includes(f)); - if (!allowed) { - log.warn( - "Forbidden: player without an allowed flare attempted to join game", - ); - ws.close(1002, "Forbidden"); - return; - } - } } - - // Check if the flag is allowed - if (clientMsg.flag !== undefined) { - if (clientMsg.flag.startsWith("!")) { - const allowed = privilegeRefresher - .get() - .isCustomFlagAllowed(clientMsg.flag, flares); - if (allowed !== true) { - log.warn(`Custom flag ${allowed}: ${clientMsg.flag}`); - ws.close(1002, `Custom flag ${allowed}`); - return; - } - } - } - - let pattern: string | undefined; - // Check if the pattern is allowed - if (clientMsg.patternName !== undefined) { - const result = privilegeRefresher - .get() - .isPatternAllowed(clientMsg.patternName, flares); - switch (result.type) { - case "allowed": - pattern = result.pattern; - break; - case "unknown": - log.warn(`Pattern ${clientMsg.patternName} unknown`); - ws.close( - 1002, - "Could not look up pattern, backend may be offline", - ); - return; - case "forbidden": - log.warn(`Pattern ${clientMsg.patternName}: ${result.reason}`); - ws.close( - 1002, - `Pattern ${clientMsg.patternName}: ${result.reason}`, - ); - return; - default: - assertNever(result); - } - } - - // Create client and add to game - const client = new Client( - clientMsg.clientID, - persistentId, - claims, - roles, - flares, - ip, - clientMsg.username, - ws, - clientMsg.flag, - pattern, - ); - - const wasFound = gm.addClient( - client, - clientMsg.gameID, - clientMsg.lastTurn, - ); - - if (!wasFound) { - log.info( - `game ${clientMsg.gameID} not found on worker ${workerId}`, - ); - // Handle game not found case - } - - // Handle other message types - } catch (error) { - ws.close(1011, "Internal server error"); - log.warn( - `error handling websocket message for ${ipAnonymize(ip)}: ${error}`.substring( - 0, - 250, - ), - ); } - }), - ); + + // Check if the flag is allowed + if (clientMsg.flag !== undefined) { + if (clientMsg.flag.startsWith("!")) { + const allowed = privilegeRefresher + .get() + .isCustomFlagAllowed(clientMsg.flag, flares); + if (allowed !== true) { + log.warn(`Custom flag ${allowed}: ${clientMsg.flag}`); + ws.close(1002, `Custom flag ${allowed}`); + return; + } + } + } + + let pattern: string | undefined; + // Check if the pattern is allowed + if (clientMsg.patternName !== undefined) { + const result = privilegeRefresher + .get() + .isPatternAllowed(clientMsg.patternName, flares); + switch (result.type) { + case "allowed": + pattern = result.pattern; + break; + case "unknown": + log.warn(`Pattern ${clientMsg.patternName} unknown`); + ws.close( + 1002, + "Could not look up pattern, backend may be offline", + ); + return; + case "forbidden": + log.warn(`Pattern ${clientMsg.patternName}: ${result.reason}`); + ws.close( + 1002, + `Pattern ${clientMsg.patternName}: ${result.reason}`, + ); + return; + default: + assertNever(result); + } + } + + // Create client and add to game + const client = new Client( + clientMsg.clientID, + persistentId, + claims, + roles, + flares, + ip, + clientMsg.username, + ws, + clientMsg.flag, + pattern, + ); + + const wasFound = gm.addClient( + client, + clientMsg.gameID, + clientMsg.lastTurn, + ); + + if (!wasFound) { + log.info(`game ${clientMsg.gameID} not found on worker ${workerId}`); + // Handle game not found case + } + + // Handle other message types + } catch (error) { + ws.close(1011, "Internal server error"); + log.warn( + `error handling websocket message for ${ipAnonymize(ip)}: ${error}`.substring( + 0, + 250, + ), + ); + } + }); ws.on("error", (error: Error) => { if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") { diff --git a/src/server/gatekeeper b/src/server/gatekeeper deleted file mode 160000 index f5f7e6362..000000000 --- a/src/server/gatekeeper +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f5f7e6362f54fc0d7d7b1d74e60a62f83fa8fb3f