From 5dc00bc3abbfc853471f04f38246d3a30b64a2a2 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 10 Mar 2025 12:40:36 -0700 Subject: [PATCH] use git commit hash verification when replaying archived games (#204) --- Dockerfile | 5 + src/client/JoinPrivateLobbyModal.ts | 134 +++++++++++++++--------- src/core/Schemas.ts | 1 + src/core/configuration/Config.ts | 2 + src/core/configuration/DefaultConfig.ts | 3 + src/core/configuration/DevConfig.ts | 3 + src/server/Archive.ts | 1 + src/server/GameServer.ts | 1 - src/server/Worker.ts | 33 +++++- upload.sh | 14 ++- 10 files changed, 138 insertions(+), 59 deletions(-) diff --git a/Dockerfile b/Dockerfile index 27b776651..96b102124 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,9 @@ # Build stage - will use your native architecture FROM --platform=$BUILDPLATFORM oven/bun:1 AS builder +ARG GIT_COMMIT=unknown +ENV GIT_COMMIT=$GIT_COMMIT + # Set the working directory for the build WORKDIR /build @@ -28,6 +31,8 @@ FROM oven/bun:1 ARG GAME_ENV=prod ENV GAME_ENV=$GAME_ENV ENV NODE_ENV=production +ARG GIT_COMMIT=unknown +ENV GIT_COMMIT=$GIT_COMMIT # Install Nginx, Supervisor and Git (for Husky) RUN apt-get update && apt-get install -y nginx supervisor && \ diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index 7d64227d6..b99892ad2 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -364,65 +364,101 @@ export class JoinPrivateLobbyModal extends LitElement { } } - private async joinLobby() { + private async joinLobby(): Promise { const lobbyId = this.lobbyIdInput.value; consolex.log(`Joining lobby with ID: ${lobbyId}`); - this.message = "Checking lobby..."; // Set initial message + this.message = "Checking lobby..."; - const config = await getServerConfigFromClient(); - const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`; try { - const response = await fetch(url, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); - const gameInfo = await response.json(); - if (gameInfo.exists) { - this.message = "Joined successfully! Waiting for game to start..."; - this.hasJoined = true; - this.dispatchEvent( - new CustomEvent("join-lobby", { - detail: { - gameID: lobbyId, - } as JoinLobbyEvent, - bubbles: true, - composed: true, - }), - ); - this.playersInterval = setInterval(() => this.pollPlayers(), 1000); - } else { - const archive_url = `/${config.workerPath(lobbyId)}/api/archived_game/${lobbyId}`; - const archive_response = await fetch(archive_url, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); - const archive_data = await archive_response.json(); - if (archive_data.exists) { - const gr = archive_data.gameRecord as GameRecord; - this.dispatchEvent( - new CustomEvent("join-lobby", { - detail: { - gameID: lobbyId, - gameRecord: gr, - } as JoinLobbyEvent, - bubbles: true, - composed: true, - }), - ); - } else { - this.message = "Lobby not found. Please check the ID and try again."; - } - } + // First, check if the game exists in active lobbies + const gameExists = await this.checkActiveLobby(lobbyId); + if (gameExists) return; + + // If not active, check archived games + const archivedGame = await this.checkArchivedGame(lobbyId); + if (archivedGame) return; + + this.message = "Lobby not found. Please check the ID and try again."; } catch (error) { consolex.error("Error checking lobby existence:", error); this.message = "An error occurred. Please try again."; } } + private async checkActiveLobby(lobbyId: string): Promise { + const config = await getServerConfigFromClient(); + const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`; + + const response = await fetch(url, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + const gameInfo = await response.json(); + + if (gameInfo.exists) { + this.message = "Joined successfully! Waiting for game to start..."; + this.hasJoined = true; + + this.dispatchEvent( + new CustomEvent("join-lobby", { + detail: { gameID: lobbyId } as JoinLobbyEvent, + bubbles: true, + composed: true, + }), + ); + + this.playersInterval = setInterval(() => this.pollPlayers(), 1000); + return true; + } + + return false; + } + + private async checkArchivedGame(lobbyId: string): Promise { + const config = await getServerConfigFromClient(); + const archiveUrl = `/${config.workerPath(lobbyId)}/api/archived_game/${lobbyId}`; + + const archiveResponse = await fetch(archiveUrl, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + const archiveData = await archiveResponse.json(); + + if ( + archiveData.success === false && + archiveData.error === "Version mismatch" + ) { + consolex.warn( + `Git commit hash mismatch for game ${lobbyId}`, + archiveData.details, + ); + this.message = + "This game was created with a different version. Cannot join."; + return true; + } + + if (archiveData.exists) { + const gameRecord = archiveData.gameRecord as GameRecord; + + this.dispatchEvent( + new CustomEvent("join-lobby", { + detail: { + gameID: lobbyId, + gameRecord: gameRecord, + } as JoinLobbyEvent, + bubbles: true, + composed: true, + }), + ); + + return true; + } + + return false; + } + private async pollPlayers() { if (!this.lobbyIdInput?.value) return; const config = await getServerConfigFromClient(); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 5146695f9..fcf6a106a 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -395,4 +395,5 @@ export const GameRecordSchema = z.object({ winner: ID.nullable(), allPlayersStats: z.record(ID, PlayerStatsSchema), version: z.enum(["v0.0.1"]), + gitCommit: z.string().nullable().optional(), }); diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 77499c1be..28b9a224e 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -100,6 +100,8 @@ export interface ServerConfig { env(): GameEnv; adminToken(): string; adminHeader(): string; + // Only available on the server + gitCommit(): string; } export interface Config { diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 113cddf3d..0adc11bb0 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -22,6 +22,9 @@ import { pastelTheme } from "./PastelTheme"; import { pastelThemeDark } from "./PastelThemeDark"; export abstract class DefaultServerConfig implements ServerConfig { + gitCommit(): string { + return process.env.GIT_COMMIT; + } adminHeader(): string { return "x-admin-key"; } diff --git a/src/core/configuration/DevConfig.ts b/src/core/configuration/DevConfig.ts index 04fa9bf50..619916787 100644 --- a/src/core/configuration/DevConfig.ts +++ b/src/core/configuration/DevConfig.ts @@ -23,6 +23,9 @@ export class DevServerConfig extends DefaultServerConfig { numWorkers(): number { return 2; } + gitCommit(): string { + return "DEV"; + } } export class DevConfig extends DefaultConfig { diff --git a/src/server/Archive.ts b/src/server/Archive.ts index ab5069875..2600e700d 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -15,6 +15,7 @@ const analyticsBucket = "openfront-analytics"; export async function archive(gameRecord: GameRecord) { try { + gameRecord.gitCommit = config.gitCommit(); // Archive to Redshift Serverless await archiveAnalyticsToS3(gameRecord); diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index e591f0d83..1fc0f0443 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -478,7 +478,6 @@ export class GameServer { for (const client of this.activeClients) { if (client.hashes.has(turnNumber)) { const clientHash = client.hashes.get(turnNumber)!; - console.log(`clientHash: ${clientHash}`); counts.set(clientHash, (counts.get(clientHash) || 0) + 1); } } diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 941b83f7f..db0516bea 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -4,7 +4,10 @@ import { WebSocketServer } from "ws"; import path from "path"; import { fileURLToPath } from "url"; import { GameManager } from "./GameManager"; -import { getServerConfigFromServer } from "../core/configuration/Config"; +import { + GameEnv, + getServerConfigFromServer, +} from "../core/configuration/Config"; import { WebSocket } from "ws"; import { Client } from "./Client"; import rateLimit from "express-rate-limit"; @@ -185,13 +188,35 @@ export function startWorker() { "/api/archived_game/:id", gatekeeper.httpHandler(LimiterType.Get, async (req, res) => { const gameRecord = await readGameRecord(req.params.id); + if (!gameRecord) { - res.json({ + return res.status(404).json({ + success: false, + error: "Game not found", exists: false, }); - return; } - res.json({ + + if ( + config.env() != GameEnv.Dev && + gameRecord.gitCommit != config.gitCommit() + ) { + console.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, exists: true, gameRecord: gameRecord, }); diff --git a/upload.sh b/upload.sh index de92eca61..61182a58e 100755 --- a/upload.sh +++ b/upload.sh @@ -54,13 +54,17 @@ if [ $? -ne 0 ]; then fi fi +GIT_COMMIT=$(git rev-parse HEAD) +echo "Git commit: $GIT_COMMIT" + # Build the Docker image echo "Building Docker image..." -docker buildx build --platform linux/amd64 -t $ECR_REPO_NAME:$VERSION_TAG . -if [ $? -ne 0 ]; then - echo "Error: Docker build failed." - exit 1 -fi +docker buildx build \ + --platform linux/amd64 \ + --build-arg GIT_COMMIT=$GIT_COMMIT \ + -t $ECR_REPO_NAME:$VERSION_TAG \ + . + # Authenticate to ECR echo "Authenticating to ECR..."