mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 06:12:29 +00:00
merge branch v0.17.3
This commit is contained in:
+52
-51
@@ -1,27 +1,29 @@
|
||||
import { GameRecord, GameID } from "../core/Schemas";
|
||||
import { S3 } from "@aws-sdk/client-s3";
|
||||
import { RedshiftData } from "@aws-sdk/client-redshift-data";
|
||||
import {
|
||||
GameEnv,
|
||||
getServerConfigFromServer,
|
||||
} from "../core/configuration/Config";
|
||||
|
||||
// Initialize AWS clients
|
||||
const s3 = new S3();
|
||||
const bucket = "openfront-games";
|
||||
const redshiftData = new RedshiftData({ region: "eu-west-1" });
|
||||
const config = getServerConfigFromServer();
|
||||
|
||||
// Redshift Serverless configuration
|
||||
const REDSHIFT_WORKGROUP = "game-analytics";
|
||||
const REDSHIFT_DATABASE = "game_archive";
|
||||
const s3 = new S3({ region: "eu-west-1" });
|
||||
|
||||
const gameBucket = "openfront-games";
|
||||
const analyticsBucket = "openfront-analytics";
|
||||
|
||||
export async function archive(gameRecord: GameRecord) {
|
||||
try {
|
||||
// Archive to Redshift Serverless
|
||||
await archiveToRedshift(gameRecord);
|
||||
await archiveAnalyticsToS3(gameRecord);
|
||||
|
||||
// Archive to S3 if there are turns
|
||||
if (gameRecord.turns.length > 0) {
|
||||
console.log(
|
||||
`${gameRecord.id}: game has more than zero turns, attempting to write to S3`,
|
||||
`${gameRecord.id}: game has more than zero turns, attempting to write to full game to S3`,
|
||||
);
|
||||
await archiveToS3(gameRecord);
|
||||
await archiveFullGameToS3(gameRecord);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${gameRecord.id}: Final archive error: ${error}`, {
|
||||
@@ -33,55 +35,54 @@ export async function archive(gameRecord: GameRecord) {
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveToRedshift(gameRecord: GameRecord) {
|
||||
const row = {
|
||||
async function archiveAnalyticsToS3(gameRecord: GameRecord) {
|
||||
// Create analytics data object (similar to what was going to Redshift)
|
||||
const analyticsData = {
|
||||
id: gameRecord.id,
|
||||
start: new Date(gameRecord.startTimestampMS),
|
||||
end: new Date(gameRecord.endTimestampMS),
|
||||
env: config.env(),
|
||||
start_time: new Date(gameRecord.startTimestampMS).toISOString(),
|
||||
end_time: new Date(gameRecord.endTimestampMS).toISOString(),
|
||||
duration_seconds: gameRecord.durationSeconds,
|
||||
number_turns: gameRecord.num_turns,
|
||||
game_mode: gameRecord.gameConfig.gameType,
|
||||
winner: gameRecord.winner,
|
||||
difficulty: gameRecord.gameConfig.difficulty,
|
||||
map: gameRecord.gameConfig.gameMap,
|
||||
players: JSON.stringify(
|
||||
gameRecord.players.map((p) => ({
|
||||
username: p.username,
|
||||
ip: p.ip,
|
||||
persistentID: p.persistentID,
|
||||
clientID: p.clientID,
|
||||
})),
|
||||
),
|
||||
mapType: gameRecord.gameConfig.gameMap,
|
||||
players: gameRecord.players.map((p) => ({
|
||||
username: p.username,
|
||||
ip: p.ip,
|
||||
persistentID: p.persistentID,
|
||||
clientID: p.clientID,
|
||||
})),
|
||||
};
|
||||
|
||||
// Convert the row to SQL parameters for insertion
|
||||
const params = {
|
||||
Sql: `
|
||||
INSERT INTO game_results (
|
||||
id, start, end, duration_seconds, number_turns, game_mode,
|
||||
winner, difficulty, map, players
|
||||
) VALUES (
|
||||
'${row.id}',
|
||||
'${row.start.toISOString()}',
|
||||
'${row.end.toISOString()}',
|
||||
${row.duration_seconds},
|
||||
${row.number_turns},
|
||||
'${row.game_mode}',
|
||||
'${row.winner}',
|
||||
'${row.difficulty}',
|
||||
'${row.map}',
|
||||
JSON_PARSE('${row.players}')
|
||||
)
|
||||
`,
|
||||
WorkgroupName: REDSHIFT_WORKGROUP,
|
||||
Database: REDSHIFT_DATABASE,
|
||||
};
|
||||
try {
|
||||
// Store analytics data using just the game ID as the key
|
||||
const analyticsKey = `${gameRecord.id}.json`;
|
||||
|
||||
await redshiftData.executeStatement(params);
|
||||
console.log(`${gameRecord.id}: wrote game metadata to Redshift`);
|
||||
await s3.putObject({
|
||||
Bucket: analyticsBucket,
|
||||
Key: analyticsKey,
|
||||
Body: JSON.stringify(analyticsData),
|
||||
ContentType: "application/json",
|
||||
});
|
||||
|
||||
console.log(`${gameRecord.id}: successfully wrote game analytics to S3`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`${gameRecord.id}: Error writing game analytics to S3: ${error}`,
|
||||
{
|
||||
message: error?.message || error,
|
||||
stack: error?.stack,
|
||||
name: error?.name,
|
||||
...(error && typeof error === "object" ? error : {}),
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveToS3(gameRecord: GameRecord) {
|
||||
async function archiveFullGameToS3(gameRecord: GameRecord) {
|
||||
// Create a deep copy to avoid modifying the original
|
||||
const recordCopy = JSON.parse(JSON.stringify(gameRecord));
|
||||
|
||||
@@ -93,7 +94,7 @@ async function archiveToS3(gameRecord: GameRecord) {
|
||||
|
||||
try {
|
||||
await s3.putObject({
|
||||
Bucket: bucket,
|
||||
Bucket: gameBucket,
|
||||
Key: recordCopy.id,
|
||||
Body: JSON.stringify(recordCopy),
|
||||
ContentType: "application/json",
|
||||
@@ -110,7 +111,7 @@ export async function readGameRecord(gameId: GameID): Promise<GameRecord> {
|
||||
try {
|
||||
// Check if file exists and download in one operation
|
||||
const response = await s3.getObject({
|
||||
Bucket: bucket,
|
||||
Bucket: gameBucket,
|
||||
Key: gameId,
|
||||
});
|
||||
|
||||
@@ -133,7 +134,7 @@ export async function readGameRecord(gameId: GameID): Promise<GameRecord> {
|
||||
export async function gameRecordExists(gameId: GameID): Promise<boolean> {
|
||||
try {
|
||||
await s3.headObject({
|
||||
Bucket: bucket,
|
||||
Bucket: gameBucket,
|
||||
Key: gameId,
|
||||
});
|
||||
return true;
|
||||
|
||||
+45
-10
@@ -4,7 +4,10 @@ import express from "express";
|
||||
import { GameMapType, GameType, Difficulty } from "../core/game/Game";
|
||||
import { generateID } from "../core/Util";
|
||||
import { PseudoRandom } from "../core/PseudoRandom";
|
||||
import { GameEnv, getServerConfig } from "../core/configuration/Config";
|
||||
import {
|
||||
GameEnv,
|
||||
getServerConfigFromServer,
|
||||
} from "../core/configuration/Config";
|
||||
import { GameInfo } from "../core/Schemas";
|
||||
import path from "path";
|
||||
import rateLimit from "express-rate-limit";
|
||||
@@ -12,7 +15,7 @@ import { fileURLToPath } from "url";
|
||||
import { isHighTrafficTime } from "./Util";
|
||||
import { gatekeeper, LimiterType } from "./Gatekeeper";
|
||||
|
||||
const config = getServerConfig();
|
||||
const config = getServerConfigFromServer();
|
||||
const readyWorkers = new Set();
|
||||
|
||||
const app = express();
|
||||
@@ -21,8 +24,25 @@ const server = http.createServer(app);
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
app.use(express.json());
|
||||
// Serve static files from the 'out' directory
|
||||
app.use(express.static(path.join(__dirname, "../../out")));
|
||||
app.use(
|
||||
express.static(path.join(__dirname, "../../static"), {
|
||||
maxAge: "1y", // Set max-age to 1 year for all static assets
|
||||
setHeaders: (res, path) => {
|
||||
// You can conditionally set different cache times based on file types
|
||||
if (path.endsWith(".html")) {
|
||||
// HTML files get shorter cache time
|
||||
res.setHeader("Cache-Control", "public, max-age=60");
|
||||
} else if (path.match(/\.(js|css|svg)$/)) {
|
||||
// JS, CSS, SVG get long cache with immutable
|
||||
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||
} else if (path.match(/\.(bin|dat|exe|dll|so|dylib)$/)) {
|
||||
// Binary files also get long cache with immutable
|
||||
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||
}
|
||||
// Other file types use the default maxAge setting
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.use(express.json());
|
||||
|
||||
app.set("trust proxy", 3);
|
||||
@@ -122,9 +142,19 @@ export async function startMaster() {
|
||||
});
|
||||
}
|
||||
|
||||
app.get(
|
||||
"/api/env",
|
||||
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
|
||||
const envConfig = {
|
||||
game_env: process.env.GAME_ENV || "prod",
|
||||
};
|
||||
res.json(envConfig);
|
||||
}),
|
||||
);
|
||||
|
||||
// Add lobbies endpoint to list public games for this worker
|
||||
app.get(
|
||||
"/public_lobbies",
|
||||
"/api/public_lobbies",
|
||||
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
|
||||
res.send(publicLobbiesJsonStr);
|
||||
}),
|
||||
@@ -135,7 +165,7 @@ async function fetchLobbies(): Promise<void> {
|
||||
|
||||
for (const gameID of publicLobbyIDs) {
|
||||
const port = config.workerPort(gameID);
|
||||
const promise = fetch(`http://localhost:${port}/game/${gameID}`)
|
||||
const promise = fetch(`http://localhost:${port}/api/game/${gameID}`)
|
||||
.then((resp) => resp.json())
|
||||
.then((json) => {
|
||||
return json as GameInfo;
|
||||
@@ -191,17 +221,18 @@ async function schedulePublicGame() {
|
||||
disableNPCs: false,
|
||||
bots: 400,
|
||||
};
|
||||
|
||||
const workerPath = config.workerPath(gameID);
|
||||
|
||||
// Send request to the worker to start the game
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://localhost:${config.workerPort(gameID)}/create_game/${gameID}`,
|
||||
`http://localhost:${config.workerPort(gameID)}/api/create_game/${gameID}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Internal-Request": "true",
|
||||
[config.adminHeader()]: config.adminToken(),
|
||||
"X-Internal-Request": "true", // Special header for internal requests
|
||||
},
|
||||
body: JSON.stringify({
|
||||
gameID: gameID,
|
||||
@@ -209,9 +240,11 @@ async function schedulePublicGame() {
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to schedule public game: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
@@ -222,9 +255,11 @@ async function schedulePublicGame() {
|
||||
}
|
||||
}
|
||||
|
||||
// Map rotation management (moved from GameManager)
|
||||
let mapsPlaylist: GameMapType[] = [];
|
||||
const random = new PseudoRandom(123);
|
||||
|
||||
// Get the next map in rotation
|
||||
function getNextMap(): GameMapType {
|
||||
if (mapsPlaylist.length > 0) {
|
||||
return mapsPlaylist.shift()!;
|
||||
@@ -275,5 +310,5 @@ function sleep(ms: number): Promise<void> {
|
||||
|
||||
// SPA fallback route
|
||||
app.get("*", function (req, res) {
|
||||
res.sendFile(path.join(__dirname, "../../out/index.html"));
|
||||
res.sendFile(path.join(__dirname, "../../static/index.html"));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Gatekeeper
|
||||
|
||||
Security module for botting, rate limiting, fingerprinting, etc.
|
||||
+34
-13
@@ -4,7 +4,7 @@ import { WebSocketServer } from "ws";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { GameManager } from "./GameManager";
|
||||
import { getServerConfig } from "../core/configuration/Config";
|
||||
import { getServerConfigFromServer } from "../core/configuration/Config";
|
||||
import { WebSocket } from "ws";
|
||||
import { Client } from "./Client";
|
||||
import rateLimit from "express-rate-limit";
|
||||
@@ -13,9 +13,9 @@ import { GameConfig, GameRecord, LogSeverity } from "../core/Schemas";
|
||||
import { slog } from "./StructuredLog";
|
||||
import { GameType } from "../core/game/Game";
|
||||
import { archive } from "./Archive";
|
||||
import { LimiterType, gatekeeper } from "./Gatekeeper";
|
||||
import { gatekeeper, LimiterType } from "./Gatekeeper";
|
||||
|
||||
const config = getServerConfig();
|
||||
const config = getServerConfigFromServer();
|
||||
|
||||
// Worker setup
|
||||
export function startWorker() {
|
||||
@@ -69,7 +69,7 @@ export function startWorker() {
|
||||
|
||||
// Endpoint to create a private lobby
|
||||
app.post(
|
||||
"/create_game/:id",
|
||||
"/api/create_game/:id",
|
||||
gatekeeper.httpHandler(LimiterType.Post, async (req, res) => {
|
||||
const id = req.params.id;
|
||||
if (!id) {
|
||||
@@ -79,9 +79,9 @@ export function startWorker() {
|
||||
// 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 (gc?.gameType == GameType.Public && !isAdmin(req)) {
|
||||
if (gc?.gameType == GameType.Public && !isLocalhost(req)) {
|
||||
console.warn(
|
||||
`cannot create public game ${id}, ip ${clientIP} not admin`,
|
||||
`cannot create public game ${id}, ip ${clientIP} not localhost`,
|
||||
);
|
||||
return res.status(400);
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export function startWorker() {
|
||||
|
||||
// Add other endpoints from your original server
|
||||
app.post(
|
||||
"/start_game/:id",
|
||||
"/api/start_game/:id",
|
||||
gatekeeper.httpHandler(LimiterType.Post, async (req, res) => {
|
||||
console.log(`starting private lobby with id ${req.params.id}`);
|
||||
const game = gm.game(req.params.id);
|
||||
@@ -126,7 +126,7 @@ export function startWorker() {
|
||||
);
|
||||
|
||||
app.put(
|
||||
"/game/:id",
|
||||
"/api/game/:id",
|
||||
gatekeeper.httpHandler(LimiterType.Put, async (req, res) => {
|
||||
// TODO: only update public game if from local host
|
||||
const lobbyID = req.params.id;
|
||||
@@ -157,7 +157,7 @@ export function startWorker() {
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/game/:id/exists",
|
||||
"/api/game/:id/exists",
|
||||
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
|
||||
const lobbyId = req.params.id;
|
||||
res.json({
|
||||
@@ -167,7 +167,7 @@ export function startWorker() {
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/game/:id",
|
||||
"/api/game/:id",
|
||||
gatekeeper.httpHandler(LimiterType.Get, async (req, res) => {
|
||||
const game = gm.game(req.params.id);
|
||||
if (game == null) {
|
||||
@@ -179,7 +179,7 @@ export function startWorker() {
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/archive_singleplayer_game",
|
||||
"/api/archive_singleplayer_game",
|
||||
gatekeeper.httpHandler(LimiterType.Post, async (req, res) => {
|
||||
const gameRecord: GameRecord = req.body;
|
||||
const clientIP = req.ip || req.socket.remoteAddress || "unknown";
|
||||
@@ -320,6 +320,27 @@ export function startWorker() {
|
||||
});
|
||||
}
|
||||
|
||||
const isAdmin = (req: Request): boolean => {
|
||||
return req.headers[config.adminHeader()] === config.adminToken();
|
||||
const isLocalhost = (req: Request): boolean => {
|
||||
// Get client IP address from various possible sources
|
||||
const clientIP =
|
||||
req.ip ||
|
||||
req.socket.remoteAddress ||
|
||||
(req.headers["x-forwarded-for"] as string)?.split(",").shift() ||
|
||||
"unknown";
|
||||
|
||||
// Check if the request is from a loopback address
|
||||
const isLoopbackIP =
|
||||
// IPv4 localhost
|
||||
clientIP === "127.0.0.1" ||
|
||||
// IPv6 localhost
|
||||
clientIP === "::1" ||
|
||||
// Full loopback range
|
||||
clientIP.startsWith("127.");
|
||||
|
||||
// Check hostname
|
||||
const isLocalHostname =
|
||||
req.hostname === "localhost" || req.headers.host?.startsWith("localhost:");
|
||||
|
||||
// Consider request local if either IP is loopback or hostname is localhost
|
||||
return isLoopbackIP || isLocalHostname;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user