mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-04 18:56:07 +00:00
Merge main into strict
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -29,6 +29,8 @@ const frequency = {
|
||||
Japan: 1,
|
||||
BlackSea: 1,
|
||||
FaroeIslands: 1,
|
||||
FalklandIslands: 1,
|
||||
Baikal: 1,
|
||||
};
|
||||
|
||||
interface MapWithMode {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user