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
This commit is contained in:
evanpelle
2025-09-05 10:11:16 -07:00
parent 5699ef1e39
commit 9a434fd03a
5 changed files with 456 additions and 666 deletions
+95 -102
View File
@@ -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,
-160
View File
@@ -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<unknown>,
) => (req: Request, res: Response, next: NextFunction) => Promise<void>;
// The wrapper for WebSocket message handlers with rate limiting
wsHandler: (
req: http.IncomingMessage | string,
fn: (message: string) => Promise<void>,
) => (message: string) => Promise<void>;
}
let gk: Gatekeeper | null = null;
async function getGatekeeperCached(): Promise<Gatekeeper> {
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<Gatekeeper> {
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<Gatekeeper>) {}
httpHandler(
limiterType: LimiterType,
fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>,
) {
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<void>,
) {
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<unknown>,
) {
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<void>,
) {
return async (message: string) => {
try {
await fn(message);
} catch (error) {
console.error("WebSocket handler error:", error);
}
};
}
}
export const gatekeeper: Gatekeeper = new GatekeeperWrapper(() =>
getGatekeeperCached(),
);
+38 -48
View File
@@ -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<number> {
const fetchPromises: Promise<GameInfo | null>[] = [];
+323 -355
View File
@@ -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") {