From 2fc81c7d176d7b09c4690ccbc03c213cedf4dcb0 Mon Sep 17 00:00:00 2001 From: Evan Date: Sat, 14 Dec 2024 10:03:05 -0800 Subject: [PATCH] store client ips in bigquery table --- TODO.txt | 1 + src/client/GameRunner.ts | 8 +++--- src/client/LocalServer.ts | 14 +++++++---- src/client/Main.ts | 37 ---------------------------- src/client/SinglePlayerModal.ts | 4 +-- src/client/Transport.ts | 4 +-- src/core/Schemas.ts | 17 +++++++------ src/core/Util.ts | 26 ++++++++++++++------ src/server/Archive.ts | 7 +++++- src/server/GameManager.ts | 6 ++--- src/server/GameServer.ts | 43 ++++++++++++++++++++------------- src/server/Server.ts | 18 ++++++++------ 12 files changed, 90 insertions(+), 95 deletions(-) diff --git a/TODO.txt b/TODO.txt index c180416f0..76e7665fb 100644 --- a/TODO.txt +++ b/TODO.txt @@ -238,6 +238,7 @@ * make boats work on oceania 12/12/2024 * add capture alert DONE 12/13/2024 * better emojis 🏳️🤦‍♂️🖕☮️🫡😡😈🤡 DONE 12/13/2024 +* store ips in bigquery table DONE 12/14/2024 * bug: player names not updating sometimes * make player editeable configs * games disconnects on multplayer after some time diff --git a/src/client/GameRunner.ts b/src/client/GameRunner.ts index eca5d4e94..8bbcf9985 100644 --- a/src/client/GameRunner.ts +++ b/src/client/GameRunner.ts @@ -7,7 +7,7 @@ import { createRenderer, GameRenderer } from "./graphics/GameRenderer"; import { InputHandler, MouseUpEvent, ZoomEvent, DragEvent, MouseDownEvent } from "./InputHandler" import { ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, ClientMessageSchema, GameConfig, GameID, Intent, ServerMessage, ServerMessageSchema, ServerSyncMessage, Turn } from "../core/Schemas"; import { createMiniMap, loadTerrainMap, TerrainMapImpl } from "../core/game/TerrainMapLoader"; -import { and, bfs, dist, manhattanDist } from "../core/Util"; +import { and, bfs, dist, generateID, manhattanDist } from "../core/Util"; import { WinCheckExecution } from "../core/execution/WinCheckExecution"; import { SendAttackIntentEvent, SendSpawnIntentEvent, Transport } from "./Transport"; import { createCanvas } from "./graphics/Utils"; @@ -20,14 +20,13 @@ export interface LobbyConfig { gameType: GameType playerName: () => string gameID: GameID - ip: string | null map: GameMap | null difficulty: Difficulty | null } export function joinLobby(lobbyConfig: LobbyConfig, onjoin: () => void): () => void { - const clientID = uuidv4() - const playerID = uuidv4() + const clientID = generateID() + const playerID = generateID() const eventBus = new EventBus() const config = getConfig() @@ -45,7 +44,6 @@ export function joinLobby(lobbyConfig: LobbyConfig, onjoin: () => void): () => v gameConfig, eventBus, lobbyConfig.gameID, - lobbyConfig.ip, clientID, playerID, config, diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 5c4f4a902..1fc2a678b 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -1,6 +1,6 @@ import { Config } from "../core/configuration/Config"; -import { ClientMessage, ClientMessageSchema, GameConfig, GameID, GameRecordSchema, Intent, ServerMessage, ServerStartGameMessageSchema, ServerTurnMessageSchema, Turn } from "../core/Schemas"; -import { CreateGameRecord, generateGameID } from "../core/Util"; +import { ClientID, ClientMessage, ClientMessageSchema, GameConfig, GameID, GameRecordSchema, Intent, PlayerRecord, ServerMessage, ServerStartGameMessageSchema, ServerTurnMessageSchema, Turn } from "../core/Schemas"; +import { CreateGameRecord, generateID } from "../core/Util"; export class LocalServer { @@ -13,8 +13,8 @@ export class LocalServer { private gameID: GameID - constructor(private config: Config, private gameConfig: GameConfig, private clientConnect: () => void, private clientMessage: (message: ServerMessage) => void) { - this.gameID = generateGameID() + constructor(private clientID: ClientID, private config: Config, private gameConfig: GameConfig, private clientConnect: () => void, private clientMessage: (message: ServerMessage) => void) { + this.gameID = generateID() } start() { @@ -52,7 +52,11 @@ export class LocalServer { public endGame() { console.log('local server ending game') clearInterval(this.endTurnIntervalID) - const record = CreateGameRecord(this.gameID, this.gameConfig, this.turns, this.startedAt, Date.now()) + const players: PlayerRecord[] = [{ + ip: null, + clientID: this.clientID + }] + const record = CreateGameRecord(this.gameID, this.gameConfig, players, this.turns, this.startedAt, Date.now()) // Clear turns because beacon only supports up to 64kb record.turns = [] // For unload events, sendBeacon is the only reliable method diff --git a/src/client/Main.ts b/src/client/Main.ts index 94425752d..be73dc2e1 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -16,8 +16,6 @@ import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal"; class Client { private gameStop: () => void - private ip: Promise = null - private usernameInput: UsernameInput | null = null; private joinModal: JoinPrivateLobbyModal @@ -37,7 +35,6 @@ class Client { }); setFavicon() - this.ip = getClientIP() document.addEventListener('join-lobby', this.handleJoinLobby.bind(this)); document.addEventListener('leave-lobby', this.handleLeaveLobby.bind(this)); document.addEventListener('single-player', this.handleSinglePlayer.bind(this)); @@ -65,8 +62,6 @@ class Client { private async handleJoinLobby(event: CustomEvent) { const lobby = event.detail.lobby console.log(`joining lobby ${lobby.id}`) - const clientIP = await this.ip - console.log(`got ip ${clientIP}`) if (this.gameStop != null) { console.log('joining lobby, stopping existing game') this.gameStop() @@ -76,7 +71,6 @@ class Client { gameType: event.detail.gameType, playerName: (): string => this.usernameInput.getCurrentUsername(), gameID: lobby.id, - ip: clientIP, map: event.detail.map, difficulty: event.detail.difficulty, }, @@ -98,37 +92,6 @@ class Client { } } -async function getClientIP(timeoutMs: number = 1000): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - - try { - const response: Response = await fetch('https://api.ipify.org?format=json', { - signal: controller.signal - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data: { ip: string } = await response.json(); - return data.ip; - } catch (error) { - if (error instanceof Error) { - if (error.name === 'AbortError') { - console.error('Request timed out'); - } else { - console.error('Error fetching IP:', error.message); - } - } else { - console.error('An unknown error occurred'); - } - return null; - } finally { - clearTimeout(timeoutId); - } -} - // Initialize the client when the DOM is loaded document.addEventListener('DOMContentLoaded', () => { new Client().initialize(); diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index a4fbfb759..19d178cc5 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -1,7 +1,7 @@ import { LitElement, html, css } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { Difficulty, GameMap, GameType } from '../core/game/Game'; -import { generateGameID as generateGameID } from '../core/Util'; +import { generateID as generateID } from '../core/Util'; @customElement('single-player-modal') export class SinglePlayerModal extends LitElement { @@ -128,7 +128,7 @@ export class SinglePlayerModal extends LitElement { detail: { gameType: GameType.Singleplayer, lobby: { - id: generateGameID(), + id: generateID(), }, map: this.selectedMap, difficulty: this.selectedDifficulty diff --git a/src/client/Transport.ts b/src/client/Transport.ts index cd15c5a87..acca558c8 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -102,7 +102,6 @@ export class Transport { private gameConfig: GameConfig | null, private eventBus: EventBus, private gameID: GameID, - private clientIP: string | null, private clientID: ClientID, private playerID: PlayerID, private config: Config, @@ -152,7 +151,7 @@ export class Transport { } private connectLocal(onconnect: () => void, onmessage: (message: ServerMessage) => void) { - this.localServer = new LocalServer(this.config, this.gameConfig, onconnect, onmessage) + this.localServer = new LocalServer(this.clientID, this.config, this.gameConfig, onconnect, onmessage) this.localServer.start() } @@ -195,7 +194,6 @@ export class Transport { type: "join", gameID: this.gameID, clientID: this.clientID, - clientIP: this.clientIP, lastTurn: numTurns }) ) diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 262539116..0a395ba35 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -42,6 +42,7 @@ export type ClientPingMessage = z.infer export type ClientIntentMessage = z.infer export type ClientJoinMessage = z.infer +export type PlayerRecord = z.infer export type GameRecord = z.infer const PlayerTypeSchema = z.nativeEnum(PlayerType); @@ -105,11 +106,6 @@ export const BoatAttackIntentSchema = BaseIntentSchema.extend({ y: z.number(), }) -export const UpdateNameIntentSchema = BaseIntentSchema.extend({ - type: z.literal('updateName'), - name: z.string(), -}) - export const AllianceRequestIntentSchema = BaseIntentSchema.extend({ type: z.literal('allianceRequest'), requestor: z.string(), @@ -228,20 +224,25 @@ export const ClientIntentMessageSchema = ClientBaseMessageSchema.extend({ export const ClientJoinMessageSchema = ClientBaseMessageSchema.extend({ type: z.literal('join'), - clientIP: z.string().nullable(), lastTurn: z.number() // The last turn the client saw. }) export const ClientMessageSchema = z.union([ClientPingMessageSchema, ClientIntentMessageSchema, ClientJoinMessageSchema]); +export const PlayerRecordSchema = z.object({ + clientID: z.string(), + username: z.string(), + ip: z.string().nullable(), +}) + export const GameRecordSchema = z.object({ id: z.string(), gameConfig: GameConfigSchema, + players: z.array(PlayerRecordSchema), startTimestampMS: z.number(), endTimestampMS: z.number(), durationSeconds: z.number(), date: z.string(), - usernames: z.array(z.string()), num_turns: z.number(), turns: z.array(TurnSchema) -}) \ No newline at end of file +}) diff --git a/src/core/Util.ts b/src/core/Util.ts index 8851284da..9bc1af5cd 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -5,7 +5,7 @@ import DOMPurify from 'dompurify'; import { Cell, Game, Player, TerraNullius, Tile, Unit } from "./game/Game"; import { number } from 'zod'; -import { GameConfig, GameID, GameRecord, Turn } from './Schemas'; +import { GameConfig, GameID, GameRecord, PlayerRecord, Turn } from './Schemas'; import { customAlphabet, nanoid } from 'nanoid'; export function manhattanDist(c1: Cell, c2: Cell): number { @@ -232,7 +232,15 @@ export function onlyImages(html: string) { }); } -export function CreateGameRecord(id: GameID, gameConfig: GameConfig, turns: Turn[], start: number, end: number): GameRecord { +export function CreateGameRecord( + id: GameID, + gameConfig: GameConfig, + // username does not need to be set. + players: PlayerRecord[], + turns: Turn[], + start: number, + end: number +): GameRecord { const record: GameRecord = { id: id, gameConfig: gameConfig, @@ -241,18 +249,22 @@ export function CreateGameRecord(id: GameID, gameConfig: GameConfig, turns: Turn date: new Date().toISOString().split('T')[0], turns: [] } - const usernames = new Set() + for (const turn of turns) { if (turn.intents.length != 0) { record.turns.push(turn) for (const intent of turn.intents) { - if (intent.type == 'spawn') { - usernames.add(intent.name) + if (intent.type == "spawn") { + for (const playerRecord of players) { + if (playerRecord.clientID == intent.clientID) { + playerRecord.username = intent.name + } + } } } } } - record.usernames = Array.from(usernames) + record.players = players record.durationSeconds = Math.floor((record.endTimestampMS - record.startTimestampMS) / 1000) record.num_turns = turns.length return record; @@ -262,7 +274,7 @@ export function assertNever(x: never): never { throw new Error('Unexpected value: ' + x); } -export function generateGameID(): GameID { +export function generateID(): GameID { const nanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 8) return nanoid() } \ No newline at end of file diff --git a/src/server/Archive.ts b/src/server/Archive.ts index f30a52d80..ebc9f0666 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -15,11 +15,14 @@ export async function archive(gameRecord: GameRecord) { end: new Date(gameRecord.endTimestampMS), duration_seconds: gameRecord.durationSeconds, number_turns: gameRecord.num_turns, - usernames: gameRecord.usernames, game_mode: gameRecord.gameConfig.gameType, winner: null, difficulty: gameRecord.gameConfig.difficulty, map: gameRecord.gameConfig.gameMap, + players: gameRecord.players.map(p => ({ + username: p.username, + ip: p.ip, + })), }; await bigquery @@ -29,6 +32,8 @@ export async function archive(gameRecord: GameRecord) { console.log(`wrote game metadata to BigQuery: ${gameRecord.id}`); if (gameRecord.turns.length > 0) { + // Players will see this so make sure to clear PII. + gameRecord.players.forEach(p => p.ip = "REDACTED") console.log(`writing game ${gameRecord.id} to gcs`) const bucket = storage.bucket("openfront-games"); const file = bucket.file(gameRecord.id); diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index da42a04f1..ab7351cbf 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -4,7 +4,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Client } from "./Client"; import { GamePhase, GameServer } from "./GameServer"; import { Difficulty, GameMap, GameType } from "../core/game/Game"; -import { generateGameID } from "../core/Util"; +import { generateID } from "../core/Util"; @@ -39,7 +39,7 @@ export class GameManager { } createPrivateGame(): string { - const id = generateGameID() + const id = generateID() this.games.push(new GameServer( id, Date.now(), @@ -79,7 +79,7 @@ export class GameManager { if (now > this.lastNewLobby + this.config.gameCreationRate()) { this.lastNewLobby = now lobbies.push(new GameServer( - generateGameID(), + generateID(), now, true, this.config, diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 81c47e7e2..b0186e22c 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -1,10 +1,10 @@ -import { ClientMessage, ClientMessageSchema, GameConfig, GameRecordSchema, Intent, ServerPingMessageSchema, ServerStartGameMessage, ServerStartGameMessageSchema, ServerTurnMessageSchema, Turn } from "../core/Schemas"; +import { ClientID, ClientMessage, ClientMessageSchema, GameConfig, GameRecordSchema, Intent, PlayerRecord, ServerPingMessageSchema, ServerStartGameMessage, ServerStartGameMessageSchema, ServerTurnMessageSchema, Turn } from "../core/Schemas"; import { Config } from "../core/configuration/Config"; import { Client } from "./Client"; import WebSocket from 'ws'; import { slog } from "./StructuredLog"; import { Storage } from '@google-cloud/storage'; -import { CreateGameRecord, CreateGameRecord as ProcessRecord } from "../core/Util"; +import { CreateGameRecord } from "../core/Util"; import { archive } from "./Archive"; import { arc } from "d3"; @@ -22,7 +22,9 @@ export class GameServer { private turns: Turn[] = [] private intents: Intent[] = [] - private clients: Client[] = [] + private activeClients: Client[] = [] + // Used for record record keeping + private allClients: Map = new Map() private _hasStarted = false private _startTime: number = null @@ -35,7 +37,7 @@ export class GameServer { private config: Config, private gameConfig: GameConfig, - ) {} + ) { } public updateGameConfig(gameConfig: GameConfig): void { if (gameConfig.gameMap != null) { @@ -55,13 +57,16 @@ export class GameServer { isRejoin: lastTurn > 0 }) // Remove stale client if this is a reconnect - const existing = this.clients.find(c => c.id == client.id) + const existing = this.activeClients.find(c => c.id == client.id) if (existing != null) { existing.ws.removeAllListeners('message') } - this.clients = this.clients.filter(c => c.id != client.id) - this.clients.push(client) + this.activeClients = this.activeClients.filter(c => c.id != client.id) + this.activeClients.push(client) client.lastPing = Date.now() + + this.allClients.set(client.id, client) + client.ws.on('message', (message: string) => { const clientMsg: ClientMessage = ClientMessageSchema.parse(JSON.parse(message)) if (clientMsg.type == "intent") { @@ -77,7 +82,7 @@ export class GameServer { }) client.ws.on('close', () => { console.log(`client ${client.id} disconnected`) - this.clients = this.clients.filter(c => c.id != client.id) + this.activeClients = this.activeClients.filter(c => c.id != client.id) }) // In case a client joined the game late and missed the start message. @@ -87,7 +92,7 @@ export class GameServer { } public numClients(): number { - return this.clients.length + return this.activeClients.length } public startTime(): number { @@ -104,7 +109,7 @@ export class GameServer { this._startTime = Date.now() this.endTurnIntervalID = setInterval(() => this.endTurn(), this.config.turnIntervalMs()); - this.clients.forEach(c => { + this.activeClients.forEach(c => { console.log(`game ${this.id} sending start message to ${c.id}`) this.sendStartGameMsg(c.ws, 0) }) @@ -139,7 +144,7 @@ export class GameServer { turn: pastTurn } )) - this.clients.forEach(c => { + this.activeClients.forEach(c => { c.ws.send(msg) }) } @@ -147,7 +152,7 @@ export class GameServer { async endGame() { // Close all WebSocket connections clearInterval(this.endTurnIntervalID); - this.clients.forEach(client => { + this.activeClients.forEach(client => { client.ws.removeAllListeners('message'); // TODO: remove this? if (client.ws.readyState === WebSocket.OPEN) { client.ws.close(1000, "game has ended"); @@ -156,7 +161,11 @@ export class GameServer { console.log(`ending game ${this.id} with ${this.turns.length} turns`) try { if (this.turns.length > 100) { - const record = CreateGameRecord(this.id, this.gameConfig, this.turns, this._startTime, Date.now()) + const playerRecords: PlayerRecord[] = Array.from(this.allClients.values()).map(client => ({ + ip: client.ip, + clientID: client.id, + })); + const record = CreateGameRecord(this.id, this.gameConfig, playerRecords, this.turns, this._startTime, Date.now()) archive(record) } } catch (error) { @@ -167,7 +176,7 @@ export class GameServer { phase(): GamePhase { const now = Date.now() const alive = [] - for (const client of this.clients) { + for (const client of this.activeClients) { if (now - client.lastPing > 60_000) { console.log(`no pings from ${client.id}, terminating connection`) if (client.ws.readyState === WebSocket.OPEN) { @@ -177,14 +186,14 @@ export class GameServer { alive.push(client) } } - this.clients = alive + this.activeClients = alive if (now > this.createdAt + this.config.lobbyLifetime() + this.maxGameDuration) { console.warn(`game past max duration ${this.id}`) return GamePhase.Finished } if (!this.isPublic) { if (this._hasStarted) { - if (this.clients.length == 0) { + if (this.activeClients.length == 0) { console.log(`private game: ${this.id} complete`) return GamePhase.Finished } else { @@ -199,7 +208,7 @@ export class GameServer { return GamePhase.Lobby } - if (this.clients.length == 0 && now > this.createdAt + this.config.lobbyLifetime() + 30 * 60) { // wait at least 30s before ending game + if (this.activeClients.length == 0 && now > this.createdAt + this.config.lobbyLifetime() + 30 * 60) { // wait at least 30s before ending game return GamePhase.Finished } diff --git a/src/server/Server.ts b/src/server/Server.ts index 01230e1e2..d48995a32 100644 --- a/src/server/Server.ts +++ b/src/server/Server.ts @@ -43,17 +43,18 @@ app.post('/private_lobby', (req, res) => { }); }); - app.post('/archive_singleplayer_game', (req, res) => { try { - const gameRecord = req.body + const gameRecord: GameRecord = req.body + const clientIP = req.ip || req.socket.remoteAddress || 'unknown'; // Added this line + if (!gameRecord) { console.log('game record not found in request') res.status(404).json({ error: 'Game record not found' }); return; } + gameRecord.players.forEach(p => p.ip = clientIP) GameRecordSchema.parse(gameRecord); - console.log(`archiving singleplayer game ${gameRecord.id}`) archive(gameRecord) res.json({ success: true, @@ -91,17 +92,20 @@ app.get('/private_lobby/:id', (req, res) => { }); }); -wss.on('connection', (ws) => { - +wss.on('connection', (ws, req) => { ws.on('message', (message: string) => { const clientMsg: ClientMessage = ClientMessageSchema.parse(JSON.parse(message)) slog('websocket_msg', 'server received websocket message', clientMsg, LogSeverity.DEBUG) if (clientMsg.type == "join") { - gm.addClient(new Client(clientMsg.clientID, clientMsg.clientIP, ws), clientMsg.gameID, clientMsg.lastTurn) + const forwarded = req.headers['x-forwarded-for'] + const ip = Array.isArray(forwarded) + ? forwarded[0] // Get the first IP if it's an array + : forwarded || req.socket.remoteAddress; + + gm.addClient(new Client(clientMsg.clientID, ip, ws), clientMsg.gameID, clientMsg.lastTurn) } // TODO: send error message }) - }); function runGame() {