Merge main into strict

This commit is contained in:
Scott Anderson
2025-05-13 03:41:42 -04:00
99 changed files with 3042 additions and 562 deletions
+2
View File
@@ -1,4 +1,5 @@
import WebSocket from "ws";
import { TokenPayload } from "../core/ApiSchemas";
import { PlayerID, Tick } from "../core/game/Game";
import { ClientID } from "../core/Schemas";
import { generateID } from "../core/Util";
@@ -13,6 +14,7 @@ export class Client {
constructor(
public readonly clientID: ClientID,
public readonly persistentID: string,
public readonly claims: TokenPayload | null,
public readonly ip: string,
public readonly username: string,
public readonly ws: WebSocket,
+1 -1
View File
@@ -34,12 +34,12 @@ export class GameManager {
gameType: GameType.Private,
difficulty: Difficulty.Medium,
disableNPCs: false,
disableNukes: false,
infiniteGold: false,
infiniteTroops: false,
instantBuild: false,
gameMode: GameMode.FFA,
bots: 400,
disabledUnits: [],
...gameConfig,
});
this.games.set(id, game);
+61 -34
View File
@@ -1,3 +1,4 @@
import ipAnonymize from "ip-anonymize";
import { Logger } from "winston";
import WebSocket from "ws";
import {
@@ -57,6 +58,8 @@ export class GameServer {
private _hasPrestarted = false;
private kickedClients: Set<ClientID> = new Set();
constructor(
public readonly id: string,
readonly log_: Logger,
@@ -77,10 +80,7 @@ export class GameServer {
if (typeof gameConfig.disableNPCs !== "undefined") {
this.gameConfig.disableNPCs = gameConfig.disableNPCs;
}
if (typeof gameConfig.disableNukes !== "undefined") {
this.gameConfig.disableNukes = gameConfig.disableNukes;
}
if (typeof gameConfig.bots !== "undefined") {
if (gameConfig.bots != null) {
this.gameConfig.bots = gameConfig.bots;
}
if (typeof gameConfig.infiniteGold !== "undefined") {
@@ -95,16 +95,27 @@ export class GameServer {
if (typeof gameConfig.gameMode !== "undefined") {
this.gameConfig.gameMode = gameConfig.gameMode;
}
if (gameConfig.disabledUnits != null) {
this.gameConfig.disabledUnits = gameConfig.disabledUnits;
}
if (gameConfig.playerTeams != null) {
this.gameConfig.playerTeams = gameConfig.playerTeams;
}
}
public addClient(client: Client, lastTurn: number) {
if (this.kickedClients.has(client.clientID)) {
this.log.warn(`cannot add client, already kicked`, {
clientID: client.clientID,
});
return;
}
this.log.info("client (re)joining game", {
clientID: client.clientID,
persistentID: client.persistentID,
clientIP: client.ip,
clientIP: ipAnonymize(client.ip),
isRejoin: lastTurn > 0,
});
@@ -116,7 +127,7 @@ export class GameServer {
) {
this.log.warn("cannot add client, already have 3 ips", {
clientID: client.clientID,
clientIP: client.ip,
clientIP: ipAnonymize(client.ip),
});
return;
}
@@ -126,11 +137,21 @@ export class GameServer {
(c) => c.clientID == client.clientID,
);
if (existing != null) {
if (client.persistentID !== existing.persistentID) {
this.log.error("persistent ids do not match", {
clientID: client.clientID,
clientIP: ipAnonymize(client.ip),
clientPersistentID: client.persistentID,
existingIP: ipAnonymize(existing.ip),
existingPersistentID: existing.persistentID,
});
return;
}
existing.ws.removeAllListeners("message");
this.activeClients = this.activeClients.filter(
(c) => c.clientID != client.clientID,
);
}
this.activeClients = this.activeClients.filter(
(c) => c.clientID != client.clientID,
);
this.activeClients.push(client);
client.lastPing = Date.now();
@@ -144,34 +165,16 @@ export class GameServer {
try {
clientMsg = ClientMessageSchema.parse(JSON.parse(message));
} catch (error) {
throw Error(`error parsing schema for ${client.ip}`);
throw Error(`error parsing schema for ${ipAnonymize(client.ip)}`);
}
const c = this.allClients.get(clientMsg.clientID);
if (typeof c !== "undefined") {
if (c.persistentID != clientMsg.persistentID) {
if (clientMsg.type == "intent") {
if (clientMsg.intent.clientID != client.clientID) {
this.log.warn(
`Client ID ${clientMsg.clientID} sent incorrect id ${clientMsg.persistentID}, does not match persistent id ${c.persistentID}`,
{
clientID: clientMsg.clientID,
persistentID: clientMsg.persistentID,
},
`client id mismatch, client: ${client.clientID}, intent: ${clientMsg.intent.clientID}`,
);
return;
}
}
// Clear out persistent id to make sure it doesn't get sent to other clients.
clientMsg.persistentID = null;
if (clientMsg.type == "intent") {
if (clientMsg.gameID == this.id) {
this.addIntent(clientMsg.intent);
} else {
this.log.warn("client sent to wrong game", {
clientID: clientMsg.clientID,
persistentID: clientMsg.persistentID,
});
}
this.addIntent(clientMsg.intent);
}
if (clientMsg.type == "ping") {
this.lastPingUpdate = Date.now();
@@ -321,7 +324,6 @@ export class GameServer {
private endTurn() {
const pastTurn: Turn = {
turnNumber: this.turns.length,
gameID: this.id,
intents: this.intents,
};
this.turns.push(pastTurn);
@@ -369,7 +371,7 @@ export class GameServer {
const playerRecords: PlayerRecord[] = Array.from(
this.allClients.values(),
).map((client) => ({
ip: client.ip,
ip: ipAnonymize(client.ip),
clientID: client.clientID,
username: client.username,
persistentID: client.persistentID,
@@ -499,6 +501,31 @@ export class GameServer {
return this.gameConfig.gameType == GameType.Public;
}
public kickClient(clientID: ClientID): void {
if (this.kickedClients.has(clientID)) {
this.log.warn(`cannot kick client, already kicked`, {
clientID,
});
return;
}
const client = this.activeClients.find((c) => c.clientID === clientID);
if (client) {
this.log.info("Kicking client from game", {
clientID: client.clientID,
persistentID: client.persistentID,
});
client.ws.close(1000, "Kicked from game");
this.activeClients = this.activeClients.filter(
(c) => c.clientID !== clientID,
);
this.kickedClients.add(clientID);
} else {
this.log.warn(`cannot kick client, not found in game`, {
clientID,
});
}
}
private handleSynchronization() {
if (this.activeClients.length <= 1) {
return;
+2
View File
@@ -29,6 +29,8 @@ const frequency = {
Japan: 1,
BlackSea: 1,
FaroeIslands: 1,
FalklandIslands: 1,
Baikal: 1,
};
interface MapWithMode {
+33
View File
@@ -160,6 +160,39 @@ app.get(
}),
);
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 { gameID, clientID } = req.params;
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");
}
}),
);
async function fetchLobbies(): Promise<number> {
const fetchPromises: Promise<GameInfo | null>[] = [];
+53 -17
View File
@@ -1,17 +1,19 @@
import express, { NextFunction, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import http from "http";
import ipAnonymize from "ip-anonymize";
import path from "path";
import { fileURLToPath } from "url";
import { WebSocket, WebSocketServer } from "ws";
import { GameEnv } from "../core/configuration/Config";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameType } from "../core/game/Game";
import { GameConfig, GameRecord } from "../core/Schemas";
import { ClientMessageSchema, GameConfig, GameRecord } from "../core/Schemas";
import { archive, readGameRecord } from "./Archive";
import { Client } from "./Client";
import { GameManager } from "./GameManager";
import { gatekeeper, LimiterType } from "./Gatekeeper";
import { verifyClientToken } from "./jwt";
import { logger } from "./Logger";
import { initWorkerMetrics } from "./WorkerMetrics";
@@ -78,9 +80,8 @@ export function startWorker() {
const id = req.params.id;
if (!id) {
log.warn(`cannot create game, id not found`);
return;
return res.status(400).json({ error: "Game ID is required" });
}
// TODO: if game is public make sure request came from localhohst!!!
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
const gc = req.body?.gameConfig as GameConfig;
if (
@@ -88,9 +89,11 @@ export function startWorker() {
req.headers[config.adminHeader()] !== config.adminToken()
) {
log.warn(
`cannot create public game ${id}, ip ${clientIP} incorrect admin token`,
`cannot create public game ${id}, ip ${ipAnonymize(clientIP)} incorrect admin token`,
);
return res.status(400);
return res
.status(400)
.json({ error: "Invalid admin token for public game creation" });
}
// Double-check this worker should host this game
@@ -99,13 +102,13 @@ export function startWorker() {
log.warn(
`This game ${id} should be on worker ${expectedWorkerId}, but this is worker ${workerId}`,
);
return res.status(400);
return res.status(400).json({ error: "Worker, game id mismatch" });
}
const game = gm.createGame(id, gc);
log.info(
`Worker ${workerId}: IP ${clientIP} creating game ${game.isPublic() ? "Public" : "Private"} with id ${id}`,
`Worker ${workerId}: IP ${ipAnonymize(clientIP)} creating game ${game.isPublic() ? "Public" : "Private"} with id ${id}`,
);
res.json(game.gameInfo());
}),
@@ -123,7 +126,7 @@ export function startWorker() {
if (game.isPublic()) {
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
log.info(
`cannot start public game ${game.id}, game is public, ip: ${clientIP}`,
`cannot start public game ${game.id}, game is public, ip: ${ipAnonymize(clientIP)}`,
);
return;
}
@@ -139,20 +142,24 @@ export function startWorker() {
const lobbyID = req.params.id;
if (req.body.gameType == GameType.Public) {
log.info(`cannot update game ${lobbyID} to public`);
return res.status(400);
return res.status(400).json({ error: "Cannot update public game" });
}
const game = gm.game(lobbyID);
if (!game) {
return res.status(400);
return res.status(400).json({ error: "Game not found" });
}
if (game.isPublic()) {
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
log.warn(`cannot update public game ${game.id}, ip: ${clientIP}`);
return res.status(400);
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);
return res
.status(400)
.json({ error: "Cannot update game after it has started" });
}
game.updateGameConfig({
gameMap: req.body.gameMap,
@@ -162,7 +169,7 @@ export function startWorker() {
instantBuild: req.body.instantBuild,
bots: req.body.bots,
disableNPCs: req.body.disableNPCs,
disableNukes: req.body.disableNukes,
disabledUnits: req.body.disabledUnits,
gameMode: req.body.gameMode,
playerTeams: req.body.playerTeams,
});
@@ -250,6 +257,27 @@ export function startWorker() {
}),
);
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 { gameID, clientID } = req.params;
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(
@@ -263,7 +291,9 @@ export function startWorker() {
try {
// Process WebSocket messages as in your original code
// Parse and handle client messages
const clientMsg = JSON.parse(message.toString());
const clientMsg = ClientMessageSchema.parse(
JSON.parse(message.toString()),
);
if (clientMsg.type == "join") {
// Verify this worker should handle this game
@@ -275,10 +305,16 @@ export function startWorker() {
return;
}
const { persistentId, claims } = await verifyClientToken(
clientMsg.token,
config,
);
// Create client and add to game
const client = new Client(
clientMsg.clientID,
clientMsg.persistentID,
persistentId,
claims ?? null,
ip,
clientMsg.username,
ws,
@@ -302,7 +338,7 @@ export function startWorker() {
// Handle other message types
} catch (error) {
log.warn(
`error handling websocket message for ${ip}: ${error}`.substring(
`error handling websocket message for ${ipAnonymize(ip)}: ${error}`.substring(
0,
250,
),
+29
View File
@@ -0,0 +1,29 @@
import { jwtVerify } from "jose";
import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas";
import { ServerConfig } from "../core/configuration/Config";
type TokenVerificationResult = {
persistentId: string;
claims: TokenPayload | null;
};
export async function verifyClientToken(
token: string,
config: ServerConfig,
): Promise<TokenVerificationResult> {
if (token.length === 36) {
return { persistentId: token, claims: null };
}
const issuer = config.jwtIssuer();
const audience = config.jwtAudience();
const key = await config.jwkPublicKey();
const { payload, protectedHeader } = await jwtVerify(token, key, {
algorithms: ["EdDSA"],
issuer,
audience,
maxTokenAge: "6 days",
});
const claims = TokenPayloadSchema.parse(payload);
const persistentId = claims.sub;
return { persistentId, claims };
}