use git commit hash verification when replaying archived games (#204)

This commit is contained in:
evanpelle
2025-03-10 12:40:36 -07:00
committed by GitHub
parent 6a24bce213
commit 5dc00bc3ab
10 changed files with 138 additions and 59 deletions
+5
View File
@@ -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 && \
+85 -49
View File
@@ -364,65 +364,101 @@ export class JoinPrivateLobbyModal extends LitElement {
}
}
private async joinLobby() {
private async joinLobby(): Promise<void> {
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<boolean> {
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<boolean> {
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();
+1
View File
@@ -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(),
});
+2
View File
@@ -100,6 +100,8 @@ export interface ServerConfig {
env(): GameEnv;
adminToken(): string;
adminHeader(): string;
// Only available on the server
gitCommit(): string;
}
export interface Config {
+3
View File
@@ -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";
}
+3
View File
@@ -23,6 +23,9 @@ export class DevServerConfig extends DefaultServerConfig {
numWorkers(): number {
return 2;
}
gitCommit(): string {
return "DEV";
}
}
export class DevConfig extends DefaultConfig {
+1
View File
@@ -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);
-1
View File
@@ -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);
}
}
+29 -4
View File
@@ -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,
});
+9 -5
View File
@@ -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..."