From 18ef15d3aefb93f142e3a10f33890612dd5fd685 Mon Sep 17 00:00:00 2001 From: Zixer1 <99333209+Zixer1@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:10:48 -0400 Subject: [PATCH] Add allowlist for private lobbies (OFM) (#4351) **Add approved & assigned issue number here:** Resolves #4349 ## Description: 1. **Private-lobby allowlist.** `create_game` accepts an optional `allowedPublicIds`. It's set by whoever creates the lobby (admin-token gated, no client UI), the game server pulls it out of the config so it's never broadcast to clients or written to the game record, and it rejects any joiner whose OF publicId isn't on the list before they take a slot (stickily, so they can't retry on reconnect). Lobbies created without it behave exactly as before. It is off by default Previews: image image image I am not able to manually test all of it since it needs to also run the auth API (infra) and actually be connected to disc and whatnot (but still tested the refused flow).. Also, we would need to place some guards and visual error feedback, but since this only would affect casual of players and is more of a improvement to the feature, I will consider it out of scope for now. ## Please complete the following: - [x] I have added screenshots for all UI updates (no UI changes in this PR) - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file (no new user-facing text) - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: zixer._ --- resources/lang/en.json | 2 + src/client/HostLobbyModal.ts | 42 +++++++++++ src/core/Schemas.ts | 2 + src/server/GameManager.ts | 2 +- src/server/GameServer.ts | 22 +++++- src/server/Worker.ts | 6 ++ tests/server/AllowlistJoin.test.ts | 114 +++++++++++++++++++++++++++++ 7 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 tests/server/AllowlistJoin.test.ts 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"]); + }); +});