diff --git a/resources/lang/en.json b/resources/lang/en.json index 7ba5c4f78..63091b117 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -717,6 +717,8 @@ "options_title": "Options", "player": "Player", "player_immunity_duration": "PVP immunity duration (minutes)", + "player_whitelist": "Player Whitelist (public IDs)", + "player_whitelist_placeholder": "comma separated", "players": "Players", "random_spawn": "Random spawn", "remove_player": "Remove {username}", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index ef67dd2a3..e267044a6 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -80,6 +80,8 @@ export class HostLobbyModal extends BaseModal { @state() private startingGold: boolean = false; @state() private startingGoldValue: number | undefined = undefined; @state() private disableAlliances: boolean = false; + @state() private whitelistEnabled: boolean = false; + @state() private allowedPublicIds: string = ""; @state() private waterNukes: boolean = false; @state() private lobbyId = ""; @state() private lobbyUrlSuffix = ""; @@ -288,6 +290,19 @@ export class HostLobbyModal extends BaseModal { .onChange=${this.handleStartingGoldValueChanges} .onKeyDown=${this.handleStartingGoldValueKeyDown} >`, + html``, ]; const hostCheatInputCards = [ @@ -583,6 +598,8 @@ export class HostLobbyModal extends BaseModal { this.startingGold = false; this.startingGoldValue = undefined; this.disableAlliances = false; + this.whitelistEnabled = false; + this.allowedPublicIds = ""; this.waterNukes = false; this.hostCheatsEnabled = false; this.hostCheatInfiniteGold = false; @@ -995,6 +1012,28 @@ export class HostLobbyModal extends BaseModal { this.putGameConfig(); } + private handleWhitelistToggle = (checked: boolean) => { + this.whitelistEnabled = checked; + this.putGameConfig(); + }; + + private handleAllowedPublicIdsChange = (e: Event) => { + this.allowedPublicIds = (e.target as HTMLInputElement).value; + this.putGameConfig(); + }; + + // Comma/space/newline-separated publicIds, capped at the 200 the schema + // allows so a large paste can't make the config update fail validation. + // Undefined when empty (no allowlist). + private parseAllowedPublicIds(): string[] | undefined { + const ids = this.allowedPublicIds + .split(/[\s,]+/) + .map((s) => s.trim()) + .filter((s) => s.length > 0) + .slice(0, 200); + return ids.length > 0 ? ids : undefined; + } + private async putGameConfig() { const spawnImmunityTicks = this.spawnImmunityDurationMinutes ? this.spawnImmunityDurationMinutes * 60 * 10 @@ -1036,6 +1075,9 @@ export class HostLobbyModal extends BaseModal { ? Math.round(this.startingGoldValue * 1_000_000) : null, disableAlliances: this.disableAlliances || null, + allowedPublicIds: this.whitelistEnabled + ? (this.parseAllowedPublicIds() ?? []) + : [], waterNukes: this.waterNukes ? true : null, hostCheats: this.hostCheatsEnabled ? { diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 68bd7f724..86a2406aa 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -282,6 +282,8 @@ export const GameConfigSchema = z.object({ waterNukes: z.boolean().nullable().optional(), randomSpawn: z.boolean(), maxPlayers: z.number().optional(), + // OFM: allowlist of publicIds allowed to join (admin-only, see create_game). + allowedPublicIds: z.array(z.string()).max(200).optional(), maxTimerValue: z.number().int().min(1).max(120).nullable().optional(), // In minutes startDelay: z.number().int().min(0).max(600).nullable().optional(), // In seconds spawnImmunityDuration: z.number().int().min(0).nullable().optional(), // In ticks diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 72065a206..9a6a6476d 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -31,7 +31,7 @@ export class GameManager { joinClient( client: Client, gameID: GameID, - ): "joined" | "kicked" | "rejected" | "not_found" { + ): "joined" | "kicked" | "rejected" | "not_allowlisted" | "not_found" { const game = this.games.get(gameID); if (!game) return "not_found"; return game.joinClient(client); diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 6b8299008..b84aec37f 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -181,6 +181,9 @@ export class GameServer { this.gameConfig.disableAlliances = gameConfig.disableAlliances ?? undefined; } + if (gameConfig.allowedPublicIds !== undefined) { + this.gameConfig.allowedPublicIds = gameConfig.allowedPublicIds; + } if (gameConfig.waterNukes !== undefined) { this.gameConfig.waterNukes = gameConfig.waterNukes ?? undefined; } @@ -202,11 +205,28 @@ export class GameServer { return clientID; } - public joinClient(client: Client): "joined" | "kicked" | "rejected" { + public joinClient( + client: Client, + ): "joined" | "kicked" | "rejected" | "not_allowlisted" { if (this.kickedPersistentIds.has(client.persistentID)) { return "kicked"; } + // OFM: if an allowlist is set, only those publicIds may join. Re-checked on + // every join attempt + const allowedPublicIds = this.gameConfig.allowedPublicIds; + if ( + allowedPublicIds !== undefined && + allowedPublicIds.length > 0 && + (client.publicId === undefined || + !allowedPublicIds.includes(client.publicId)) + ) { + this.log.warn("client not on allowlist, rejecting", { + clientID: client.clientID, + }); + return "not_allowlisted"; + } + if ( this.gameConfig.maxPlayers && this.activeClients.length >= this.gameConfig.maxPlayers diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 00419bfce..af0d52f14 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -499,6 +499,12 @@ export async function startWorker() { workerId, }); ws.close(1002, "Cannot join game"); + } else if (joinResult === "not_allowlisted") { + log.info(`client not whitelisted for game ${clientMsg.gameID}`, { + gameID: clientMsg.gameID, + workerId, + }); + ws.close(1002, "You are not whitelisted"); } else if (joinResult === "rejected") { log.info(`client rejected from game ${clientMsg.gameID}`, { gameID: clientMsg.gameID, diff --git a/tests/server/AllowlistJoin.test.ts b/tests/server/AllowlistJoin.test.ts new file mode 100644 index 000000000..6de390a18 --- /dev/null +++ b/tests/server/AllowlistJoin.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/core/Schemas", async () => { + const actual = (await vi.importActual("../../src/core/Schemas")) as any; + return { + ...actual, + GameStartInfoSchema: { + safeParse: (data: any) => ({ success: true, data }), + }, + ServerPrestartMessageSchema: { + safeParse: (data: any) => ({ success: true, data }), + }, + ClientMessageSchema: { + safeParse: (data: any) => ({ success: true, data }), + }, + }; +}); + +import { GameType } from "../../src/core/game/Game"; +import { Client } from "../../src/server/Client"; +import { GameServer } from "../../src/server/GameServer"; + +function makeMockWs() { + return { + on: () => {}, + removeAllListeners: () => {}, + send: vi.fn(), + close: vi.fn(), + readyState: 1, + }; +} + +function makeClient( + clientID: string, + persistentID: string, + publicId: string | undefined, +): Client { + return new Client( + clientID, + persistentID, + null, + null, + undefined, + "127.0.0.1", + "TestUser", + null, + makeMockWs() as any, + undefined, + publicId, + [], + ); +} + +describe("GameServer - allowlist (allowedPublicIds)", () => { + let mockLogger: any; + + beforeEach(() => { + vi.useFakeTimers(); + mockLogger = { + child: vi.fn().mockReturnThis(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + function makeGame(allowedPublicIds?: string[]) { + return new GameServer("test-game", mockLogger, Date.now(), { + gameType: GameType.Private, + ...(allowedPublicIds ? { allowedPublicIds } : {}), + } as any); + } + + it("admits only listed publicIds and rejects others", () => { + const game = makeGame(["pub-ok"]); + expect(game.joinClient(makeClient("c1", "p1", "pub-ok"))).toBe("joined"); + expect(game.joinClient(makeClient("c2", "p2", "pub-no"))).toBe( + "not_allowlisted", + ); + expect(game.joinClient(makeClient("c3", "p3", undefined))).toBe( + "not_allowlisted", + ); + }); + + it("does not restrict joins when no allowlist is set", () => { + const game = makeGame(); + expect(game.joinClient(makeClient("c1", "p1", "anything"))).toBe("joined"); + }); + + it("treats an empty allowlist as no restriction", () => { + const game = makeGame([]); + expect(game.joinClient(makeClient("c1", "p1", "anything"))).toBe("joined"); + }); + + it("lets a previously-rejected player in once the allowlist is cleared", () => { + const game = makeGame(["pub-ok"]); + expect(game.joinClient(makeClient("c2", "p2", "pub-no"))).toBe( + "not_allowlisted", + ); + game.updateGameConfig({ allowedPublicIds: [] }); + expect(game.joinClient(makeClient("c2", "p2", "pub-no"))).toBe("joined"); + }); + + it("keeps allowedPublicIds on the stored config (read like other settings)", () => { + const game = makeGame(["pub-ok"]); + expect((game.gameConfig as any).allowedPublicIds).toEqual(["pub-ok"]); + }); +});