From e669c2bb44bd002a9ce531d0b064a15eba74998d Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 30 Sep 2026 06:02:18 -0400 Subject: [PATCH 1/2] feature: restrict community servers to picked players, a role or a live event --- .../servers/get_server_connection.sql | 10 + hasura/functions/servers/server_access.sql | 123 ++++++ hasura/metadata/actions.graphql | 10 + hasura/metadata/actions.yaml | 8 + .../tables/public_server_access_events.yaml | 18 + .../tables/public_server_access_players.yaml | 18 + .../default/tables/public_servers.yaml | 16 + .../databases/default/tables/tables.yaml | 2 + .../1889000000800_server_access/down.sql | 8 + .../1889000000800_server_access/up.sql | 37 ++ .../dedicated-server-config.service.ts | 89 +++++ .../dedicated-servers.controller.ts | 21 + .../dedicated-servers.module.ts | 4 +- .../server-access.service.ts | 86 ++++ src/sanctions/sanctions.controller.spec.ts | 64 ++- src/sanctions/sanctions.controller.ts | 24 +- src/sanctions/sanctions.module.ts | 14 +- src/sanctions/sanctions.service.ts | 28 +- test/server-access.spec.ts | 366 ++++++++++++++++++ test/server-map-rotation.spec.ts | 1 + 20 files changed, 924 insertions(+), 23 deletions(-) create mode 100644 hasura/functions/servers/server_access.sql create mode 100644 hasura/metadata/databases/default/tables/public_server_access_events.yaml create mode 100644 hasura/metadata/databases/default/tables/public_server_access_players.yaml create mode 100644 hasura/migrations/default/1889000000800_server_access/down.sql create mode 100644 hasura/migrations/default/1889000000800_server_access/up.sql create mode 100644 src/dedicated-servers/server-access.service.ts create mode 100644 test/server-access.spec.ts diff --git a/hasura/functions/servers/get_server_connection.sql b/hasura/functions/servers/get_server_connection.sql index 927d8ff9..79adaeab 100644 --- a/hasura/functions/servers/get_server_connection.sql +++ b/hasura/functions/servers/get_server_connection.sql @@ -11,6 +11,12 @@ BEGIN RETURN NULL; END IF; + -- A restricted server's connect info is also what lists it for guests + -- and players, so returning nothing hides it from everyone not allowed. + IF NOT can_connect_to_server(server, hasura_session) THEN + RETURN NULL; + END IF; + connection_string := CONCAT('connect ', get_server_host(server)); IF NULLIF(server.connect_password, '') IS NULL THEN @@ -40,6 +46,10 @@ BEGIN RETURN NULL; END IF; + IF NOT can_connect_to_server(server, hasura_session) THEN + RETURN NULL; + END IF; + server_host := get_server_host(server); RETURN CONCAT('steam://run/', CASE WHEN server.game = 'csgo' THEN '4465480' ELSE '730' END, '//+connect ', server_host); diff --git a/hasura/functions/servers/server_access.sql b/hasura/functions/servers/server_access.sql new file mode 100644 index 00000000..c728771f --- /dev/null +++ b/hasura/functions/servers/server_access.sql @@ -0,0 +1,123 @@ +-- An event only lets its members in while it is running, judged the way the +-- web's eventPhase does: an event with no start date has not started, and one +-- with no end date runs until it is given one. +CREATE OR REPLACE FUNCTION public.server_access_event_is_live(event public.events) +RETURNS boolean +LANGUAGE sql +STABLE +AS $$ + SELECT event.starts_at IS NOT NULL + AND event.starts_at <= now() + AND (event.ends_at IS NULL OR event.ends_at >= now()); +$$; + +-- Everyone a restricted server lets in. Staff always get in so a restricted +-- server can still be moderated. The event branches mirror is_event_member. +CREATE OR REPLACE FUNCTION public.server_allowed_steam_ids(_server_id uuid) +RETURNS TABLE (steam_id bigint) +LANGUAGE sql +STABLE +AS $$ + WITH server AS ( + SELECT s.access_min_role + FROM public.servers s + WHERE s.id = _server_id + ), + live_events AS ( + SELECT e.id, e.organizer_steam_id + FROM public.server_access_events sae + JOIN public.events e ON e.id = sae.event_id + WHERE sae.server_id = _server_id + AND public.server_access_event_is_live(e) + ) + SELECT sap.steam_id + FROM public.server_access_players sap + WHERE sap.server_id = _server_id + UNION + SELECT p.steam_id + FROM public.players p + WHERE public.is_role_below('moderator', p.role) + OR EXISTS ( + SELECT 1 FROM server + WHERE server.access_min_role IS NOT NULL + AND public.is_role_below(server.access_min_role, p.role) + ) + UNION + SELECT le.organizer_steam_id FROM live_events le + UNION + SELECT eo.steam_id + FROM live_events le + JOIN public.event_organizers eo ON eo.event_id = le.id + UNION + SELECT ep.steam_id + FROM live_events le + JOIN public.event_players ep ON ep.event_id = le.id + UNION + SELECT tr.player_steam_id + FROM live_events le + JOIN public.event_teams et ON et.event_id = le.id + JOIN public.team_roster tr ON tr.team_id = et.team_id + UNION + SELECT ttr.player_steam_id + FROM live_events le + JOIN public.event_tournaments evt ON evt.event_id = le.id + JOIN public.tournament_team_roster ttr ON ttr.tournament_id = evt.tournament_id + UNION + SELECT torg.steam_id + FROM live_events le + JOIN public.event_tournaments evt ON evt.event_id = le.id + JOIN public.tournament_organizers torg ON torg.tournament_id = evt.tournament_id + UNION + SELECT tt.owner_steam_id + FROM live_events le + JOIN public.event_tournaments evt ON evt.event_id = le.id + JOIN public.tournament_teams tt ON tt.tournament_id = evt.tournament_id + WHERE tt.owner_steam_id IS NOT NULL; +$$; + +-- Answers for one viewer without expanding the whole allowlist: a role rule on +-- a big deployment covers every registered player. +CREATE OR REPLACE FUNCTION public.can_connect_to_server(server public.servers, hasura_session json) +RETURNS boolean +LANGUAGE plpgsql +STABLE +AS $$ +DECLARE + viewer bigint; +BEGIN + IF NOT server.access_restricted THEN + RETURN true; + END IF; + + IF COALESCE(public.is_above_role('moderator', hasura_session), false) THEN + RETURN true; + END IF; + + viewer := NULLIF(NULLIF(hasura_session ->> 'x-hasura-user-id', ''), '0')::bigint; + + IF viewer IS NULL THEN + RETURN false; + END IF; + + IF server.access_min_role IS NOT NULL + AND COALESCE(public.is_above_role(server.access_min_role, hasura_session), false) THEN + RETURN true; + END IF; + + IF EXISTS ( + SELECT 1 FROM public.server_access_players sap + WHERE sap.server_id = server.id AND sap.steam_id = viewer + ) THEN + RETURN true; + END IF; + + RETURN EXISTS ( + SELECT 1 + FROM public.server_access_events sae + JOIN public.events e ON e.id = sae.event_id + WHERE sae.server_id = server.id + AND public.server_access_event_is_live(e) + AND public.is_event_member(e, viewer) + ); +END; +$$; diff --git a/hasura/metadata/actions.graphql b/hasura/metadata/actions.graphql index bc7c827a..fa0bd09d 100644 --- a/hasura/metadata/actions.graphql +++ b/hasura/metadata/actions.graphql @@ -2674,3 +2674,13 @@ type ImportWorkshopCollectionOutput { maps: [ImportedWorkshopMap!]! skipped: Int! } + +type Mutation { + setServerAccess( + server_id: uuid! + restricted: Boolean! + min_role: String + steam_ids: [String!]! + event_ids: [uuid!]! + ): SuccessOutput +} diff --git a/hasura/metadata/actions.yaml b/hasura/metadata/actions.yaml index 40f20567..78489f63 100644 --- a/hasura/metadata/actions.yaml +++ b/hasura/metadata/actions.yaml @@ -2022,6 +2022,14 @@ actions: permissions: - role: administrator comment: Add every map in a Steam workshop collection to the map catalog + - name: setServerAccess + definition: + kind: synchronous + handler: '{{HASURA_GRAPHQL_ACTIONS_HOOK}}' + forward_client_headers: true + permissions: + - role: administrator + comment: Restrict who may connect to a community server custom_types: enums: [] input_objects: diff --git a/hasura/metadata/databases/default/tables/public_server_access_events.yaml b/hasura/metadata/databases/default/tables/public_server_access_events.yaml new file mode 100644 index 00000000..eff0339c --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_server_access_events.yaml @@ -0,0 +1,18 @@ +table: + name: server_access_events + schema: public +object_relationships: + - name: event + using: + foreign_key_constraint_on: event_id + - name: server + using: + foreign_key_constraint_on: server_id +select_permissions: + - role: administrator + permission: + columns: + - event_id + - server_id + filter: {} + comment: "" diff --git a/hasura/metadata/databases/default/tables/public_server_access_players.yaml b/hasura/metadata/databases/default/tables/public_server_access_players.yaml new file mode 100644 index 00000000..0202efbd --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_server_access_players.yaml @@ -0,0 +1,18 @@ +table: + name: server_access_players + schema: public +object_relationships: + - name: player + using: + foreign_key_constraint_on: steam_id + - name: server + using: + foreign_key_constraint_on: server_id +select_permissions: + - role: administrator + permission: + columns: + - server_id + - steam_id + filter: {} + comment: "" diff --git a/hasura/metadata/databases/default/tables/public_servers.yaml b/hasura/metadata/databases/default/tables/public_servers.yaml index db428763..d4dad7c2 100644 --- a/hasura/metadata/databases/default/tables/public_servers.yaml +++ b/hasura/metadata/databases/default/tables/public_servers.yaml @@ -27,6 +27,20 @@ object_relationships: name: server_regions schema: public array_relationships: + - name: access_events + using: + foreign_key_constraint_on: + column: server_id + table: + name: server_access_events + schema: public + - name: access_players + using: + foreign_key_constraint_on: + column: server_id + table: + name: server_access_players + schema: public - name: map_rotation using: foreign_key_constraint_on: @@ -86,6 +100,8 @@ select_permissions: - role: administrator permission: columns: + - access_min_role + - access_restricted - api_password - boot_status - boot_status_detail diff --git a/hasura/metadata/databases/default/tables/tables.yaml b/hasura/metadata/databases/default/tables/tables.yaml index 1980e537..4cb4dcb1 100644 --- a/hasura/metadata/databases/default/tables/tables.yaml +++ b/hasura/metadata/databases/default/tables/tables.yaml @@ -175,6 +175,8 @@ - "!include public_plugin_versions.yaml" - "!include public_push_subscriptions.yaml" - "!include public_seasons.yaml" +- "!include public_server_access_events.yaml" +- "!include public_server_access_players.yaml" - "!include public_server_map_rotation.yaml" - "!include public_server_plugins.yaml" - "!include public_server_regions.yaml" diff --git a/hasura/migrations/default/1889000000800_server_access/down.sql b/hasura/migrations/default/1889000000800_server_access/down.sql new file mode 100644 index 00000000..7e4b6184 --- /dev/null +++ b/hasura/migrations/default/1889000000800_server_access/down.sql @@ -0,0 +1,8 @@ +DROP TABLE IF EXISTS public.server_access_events; + +DROP TABLE IF EXISTS public.server_access_players; + +ALTER TABLE public.servers + DROP CONSTRAINT IF EXISTS servers_access_min_role_fkey, + DROP COLUMN IF EXISTS access_min_role, + DROP COLUMN IF EXISTS access_restricted; diff --git a/hasura/migrations/default/1889000000800_server_access/up.sql b/hasura/migrations/default/1889000000800_server_access/up.sql new file mode 100644 index 00000000..63338503 --- /dev/null +++ b/hasura/migrations/default/1889000000800_server_access/up.sql @@ -0,0 +1,37 @@ +ALTER TABLE public.servers + ADD COLUMN IF NOT EXISTS access_restricted boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS access_min_role text; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'servers_access_min_role_fkey' + ) THEN + ALTER TABLE public.servers + ADD CONSTRAINT servers_access_min_role_fkey FOREIGN KEY (access_min_role) + REFERENCES public.e_player_roles (value) ON UPDATE CASCADE ON DELETE SET NULL; + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS public.server_access_players ( + server_id uuid NOT NULL, + steam_id bigint NOT NULL, + PRIMARY KEY (server_id, steam_id), + CONSTRAINT server_access_players_server_fkey FOREIGN KEY (server_id) + REFERENCES public.servers (id) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT server_access_players_player_fkey FOREIGN KEY (steam_id) + REFERENCES public.players (steam_id) ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS public.server_access_events ( + server_id uuid NOT NULL, + event_id uuid NOT NULL, + PRIMARY KEY (server_id, event_id), + CONSTRAINT server_access_events_server_fkey FOREIGN KEY (server_id) + REFERENCES public.servers (id) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT server_access_events_event_fkey FOREIGN KEY (event_id) + REFERENCES public.events (id) ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS server_access_events_event_idx + ON public.server_access_events (event_id); diff --git a/src/dedicated-servers/dedicated-server-config.service.ts b/src/dedicated-servers/dedicated-server-config.service.ts index b84bbdda..970698e9 100644 --- a/src/dedicated-servers/dedicated-server-config.service.ts +++ b/src/dedicated-servers/dedicated-server-config.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { PostgresService } from "../postgres/postgres.service"; import { DedicatedServersService } from "./dedicated-servers.service"; +import { RconService } from "../rcon/rcon.service"; type CommunityServer = { id: string; @@ -34,6 +35,7 @@ export class DedicatedServerConfigService { private readonly logger: Logger, private readonly postgres: PostgresService, private readonly dedicatedServers: DedicatedServersService, + private readonly rcon: RconService, ) {} public async setMapRotation( @@ -116,6 +118,93 @@ export class DedicatedServerConfigService { await this.restart(server); } + // Enforced by the Player Management plugin, which rereads the list on a + // refresh, so unlike the pod settings above this needs no restart and works + // on external servers too. + public async setAccess( + serverId: string, + access: { + restricted: boolean; + minRole: string | null; + steamIds: Array; + eventIds: Array; + }, + ): Promise { + const [server] = await this.postgres.query< + Array<{ is_dedicated: boolean; type: string; game: string }> + >(`SELECT is_dedicated, type, game FROM servers WHERE id = $1`, [serverId]); + + if (!server?.is_dedicated) { + throw new BadRequestException("Not a dedicated server"); + } + + if (server.type === "Ranked" || server.type === "Practice") { + throw new BadRequestException( + `${server.type} servers run 5Stack's own plugin set`, + ); + } + + if (server.game === "csgo") { + throw new BadRequestException( + "Restricted servers need the Player Management plugin, which is CS2 only", + ); + } + + const steamIds = [...new Set(access.steamIds)].filter((steamId) => + /^\d{1,18}$/.test(steamId), + ); + const eventIds = [...new Set(access.eventIds)]; + + await this.postgres.transaction(async (client) => { + await client.query( + `UPDATE servers + SET access_restricted = $2, access_min_role = $3 + WHERE id = $1`, + [serverId, access.restricted, access.minRole || null], + ); + + await client.query( + `DELETE FROM server_access_players WHERE server_id = $1`, + [serverId], + ); + + await client.query( + `INSERT INTO server_access_players (server_id, steam_id) + SELECT $1, steam_id FROM unnest($2::bigint[]) AS steam_id`, + [serverId, steamIds], + ); + + await client.query( + `DELETE FROM server_access_events WHERE server_id = $1`, + [serverId], + ); + + await client.query( + `INSERT INTO server_access_events (server_id, event_id) + SELECT $1, event_id FROM unnest($2::uuid[]) AS event_id`, + [serverId, eventIds], + ); + }); + + await this.refreshPlayerManagement(serverId); + } + + // Best effort: an offline server picks the list up on its next sync anyway. + private async refreshPlayerManagement(serverId: string): Promise { + try { + const rcon = await this.rcon.connect(serverId); + + if (rcon) { + await rcon.send("player_management_refresh"); + } + } catch (error) { + this.logger.warn( + `[${serverId}] unable to refresh player management`, + error?.message ?? error, + ); + } + } + // Accepts a collection or a single map, as a link or a bare id: Steam answers // a collection lookup for a plain item with result 9 and no children. public async importWorkshopCollection( diff --git a/src/dedicated-servers/dedicated-servers.controller.ts b/src/dedicated-servers/dedicated-servers.controller.ts index 07b4cd2e..828fab95 100644 --- a/src/dedicated-servers/dedicated-servers.controller.ts +++ b/src/dedicated-servers/dedicated-servers.controller.ts @@ -185,6 +185,27 @@ export class DedicatedServersController { ); } + @HasuraAction() + public async setServerAccess(data: { + user: User; + server_id: string; + restricted: boolean; + min_role?: string | null; + steam_ids: Array; + event_ids: Array; + }) { + this.assertAdministrator(data.user); + + await this.dedicatedServerConfig.setAccess(data.server_id, { + restricted: data.restricted, + minRole: data.min_role ?? null, + steamIds: data.steam_ids, + eventIds: data.event_ids, + }); + + return { success: true }; + } + private assertAdministrator(user: User): void { if (!user || !isRoleAbove(user.role, "administrator")) { throw new ForbiddenException("Administrator access required"); diff --git a/src/dedicated-servers/dedicated-servers.module.ts b/src/dedicated-servers/dedicated-servers.module.ts index a207bc01..d3edfc92 100644 --- a/src/dedicated-servers/dedicated-servers.module.ts +++ b/src/dedicated-servers/dedicated-servers.module.ts @@ -1,6 +1,7 @@ import { Module } from "@nestjs/common"; import { DedicatedServersService } from "./dedicated-servers.service"; import { DedicatedServerConfigService } from "./dedicated-server-config.service"; +import { ServerAccessService } from "./server-access.service"; import { DedicatedServersController } from "./dedicated-servers.controller"; import { HasuraModule } from "src/hasura/hasura.module"; import { loggerFactory } from "src/utilities/LoggerFactory"; @@ -42,11 +43,12 @@ import { NotificationsModule } from "../notifications/notifications.module"; providers: [ DedicatedServersService, DedicatedServerConfigService, + ServerAccessService, PingDedicatedServers, ...getQueuesProcessors("DedicatedServers"), loggerFactory(), ], - exports: [DedicatedServersService], + exports: [DedicatedServersService, ServerAccessService], controllers: [DedicatedServersController], }) export class DedicatedServersModule { diff --git a/src/dedicated-servers/server-access.service.ts b/src/dedicated-servers/server-access.service.ts new file mode 100644 index 00000000..4b5f9c9c --- /dev/null +++ b/src/dedicated-servers/server-access.service.ts @@ -0,0 +1,86 @@ +import { createHash } from "crypto"; +import { Injectable } from "@nestjs/common"; +import { PostgresService } from "../postgres/postgres.service"; + +export type ServerAllowlist = { + restricted: boolean; + version: string; + steamIds: Array; +}; + +export type ServerAccessSync = { + restricted: boolean; + version: string; + denied: Array; + message: string | null; +}; + +@Injectable() +export class ServerAccessService { + public static readonly OPEN_VERSION = "open"; + + constructor(private readonly postgres: PostgresService) {} + + public async allowlist(serverId: string): Promise { + const [server] = await this.postgres.query< + Array<{ access_restricted: boolean }> + >(`SELECT access_restricted FROM servers WHERE id = $1`, [serverId]); + + if (!server?.access_restricted) { + return { + restricted: false, + version: ServerAccessService.OPEN_VERSION, + steamIds: [], + }; + } + + const rows = await this.postgres.query>( + `SELECT steam_id::text AS steam_id + FROM server_allowed_steam_ids($1) + ORDER BY steam_id`, + [serverId], + ); + + const steamIds = rows.map((row) => row.steam_id); + + return { + restricted: true, + version: ServerAccessService.version(steamIds), + steamIds, + }; + } + + // Recomputed on every sync rather than stamped on write: an event starting + // or ending changes who is allowed without anything being saved. + public async forSync( + serverId: string, + presentSteamIds: Array, + ): Promise { + const access = await this.allowlist(serverId); + + if (!access.restricted) { + return { + restricted: false, + version: access.version, + denied: [], + message: null, + }; + } + + const allowed = new Set(access.steamIds); + + return { + restricted: true, + version: access.version, + denied: presentSteamIds.filter((steamId) => !allowed.has(steamId)), + message: null, + }; + } + + public static version(steamIds: Array): string { + return createHash("sha256") + .update(steamIds.join(",")) + .digest("hex") + .slice(0, 16); + } +} diff --git a/src/sanctions/sanctions.controller.spec.ts b/src/sanctions/sanctions.controller.spec.ts index 9940c8d6..ef824c64 100644 --- a/src/sanctions/sanctions.controller.spec.ts +++ b/src/sanctions/sanctions.controller.spec.ts @@ -7,12 +7,24 @@ describe("SanctionsController.syncServerSanctions", () => { const serverA = "11111111-1111-1111-1111-111111111111"; const serverB = "22222222-2222-2222-2222-222222222222"; + const open = { + restricted: false, + version: "open", + denied: [] as Array, + message: null as string | null, + }; + let syncServerSanctions: jest.Mock; + let forSync: jest.Mock; let controller: SanctionsController; beforeEach(() => { syncServerSanctions = jest.fn().mockResolvedValue([]); - controller = new SanctionsController({ syncServerSanctions } as any); + forSync = jest.fn().mockResolvedValue(open); + controller = new SanctionsController( + { syncServerSanctions } as any, + { forSync } as any, + ); }); // The middleware authenticates the body's serverId in preference to the @@ -35,7 +47,7 @@ describe("SanctionsController.syncServerSanctions", () => { plugin_version: "0.0.412", plugin_runtime: "swiftlys2", }), - ).resolves.toEqual({ sanctions: [] }); + ).resolves.toEqual({ sanctions: [], access: open }); expect(syncServerSanctions).toHaveBeenCalledWith(serverA, { steamIds: ["76561198000000001"], @@ -43,6 +55,40 @@ describe("SanctionsController.syncServerSanctions", () => { pluginRuntime: "swiftlys2", }); }); + + // Only well-formed ids reach the access check, the same filter the + // sanctions lookup applies. + it("checks access for the players the plugin reported", async () => { + await controller.syncServerSanctions(serverA, { + steam_ids: ["76561198000000001", "not-an-id", "76561198000000001"], + }); + + expect(forSync).toHaveBeenCalledWith(serverA, ["76561198000000001"]); + }); +}); + +describe("SanctionsController.serverAccessList", () => { + it("returns the allowlist in the plugin's shape", async () => { + const allowlist = jest.fn().mockResolvedValue({ + restricted: true, + version: "abc", + steamIds: ["76561198000000001"], + }); + const controller = new SanctionsController( + {} as any, + { + allowlist, + } as any, + ); + + await expect( + controller.serverAccessList("11111111-1111-1111-1111-111111111111"), + ).resolves.toEqual({ + restricted: true, + version: "abc", + steam_ids: ["76561198000000001"], + }); + }); }); describe("SanctionsModule", () => { @@ -55,9 +101,15 @@ describe("SanctionsModule", () => { new SanctionsModule().configure({ apply } as any); expect(apply).toHaveBeenCalledWith(MatchServerMiddlewareMiddleware); - expect(forRoutes).toHaveBeenCalledWith({ - path: "sanctions/server/:serverId", - method: RequestMethod.POST, - }); + expect(forRoutes).toHaveBeenCalledWith( + { + path: "sanctions/server/:serverId", + method: RequestMethod.POST, + }, + { + path: "sanctions/server/:serverId/access", + method: RequestMethod.GET, + }, + ); }); }); diff --git a/src/sanctions/sanctions.controller.ts b/src/sanctions/sanctions.controller.ts index 7eb8474c..135f37bb 100644 --- a/src/sanctions/sanctions.controller.ts +++ b/src/sanctions/sanctions.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, ForbiddenException, + Get, HttpCode, Param, Post, @@ -11,10 +12,14 @@ import { User } from "src/auth/types/User"; import { isRoleAbove } from "src/utilities/isRoleAbove"; import { SanctionsService } from "./sanctions.service"; import { SanctionType } from "./sanction-types"; +import { ServerAccessService } from "../dedicated-servers/server-access.service"; @Controller("sanctions") export class SanctionsController { - constructor(private readonly sanctionsService: SanctionsService) {} + constructor( + private readonly sanctionsService: SanctionsService, + private readonly serverAccess: ServerAccessService, + ) {} // Polled by the game-server Player Management plugin; the call doubles as // its heartbeat. @@ -42,6 +47,23 @@ export class SanctionsController { pluginVersion: body?.plugin_version, pluginRuntime: body?.plugin_runtime, }), + access: await this.serverAccess.forSync( + serverId, + SanctionsService.syncSteamIds(body?.steam_ids), + ), + }; + } + + // Fetched by the Player Management plugin when the sync reports a new + // access version, so it can refuse players at connect. + @Get("server/:serverId/access") + public async serverAccessList(@Param("serverId") serverId: string) { + const access = await this.serverAccess.allowlist(serverId); + + return { + restricted: access.restricted, + version: access.version, + steam_ids: access.steamIds, }; } diff --git a/src/sanctions/sanctions.module.ts b/src/sanctions/sanctions.module.ts index c5420766..8dcd2c95 100644 --- a/src/sanctions/sanctions.module.ts +++ b/src/sanctions/sanctions.module.ts @@ -22,9 +22,15 @@ import { SanctionsController } from "./sanctions.controller"; }) export class SanctionsModule implements NestModule { configure(consumer: MiddlewareConsumer) { - consumer.apply(MatchServerMiddlewareMiddleware).forRoutes({ - path: "sanctions/server/:serverId", - method: RequestMethod.POST, - }); + consumer.apply(MatchServerMiddlewareMiddleware).forRoutes( + { + path: "sanctions/server/:serverId", + method: RequestMethod.POST, + }, + { + path: "sanctions/server/:serverId/access", + method: RequestMethod.GET, + }, + ); } } diff --git a/src/sanctions/sanctions.service.ts b/src/sanctions/sanctions.service.ts index 631088a3..581749e5 100644 --- a/src/sanctions/sanctions.service.ts +++ b/src/sanctions/sanctions.service.ts @@ -30,6 +30,22 @@ export class SanctionsService { "warning", ]; + // Anything past 18 digits can overflow the bigint cast and fail the whole + // query; a real SteamID64 is 17. + public static syncSteamIds(value: unknown): Array { + if (!Array.isArray(value)) { + return []; + } + + return [ + ...new Set( + value + .map((steamId) => String(steamId)) + .filter((steamId) => /^\d{1,18}$/.test(steamId)), + ), + ].slice(0, SanctionsService.MAX_SYNC_STEAM_IDS); + } + public async syncServerSanctions( serverId: string, params: { @@ -51,17 +67,7 @@ export class SanctionsService { params.pluginRuntime, ); - // Anything past 18 digits can overflow the bigint cast and fail the whole - // query; a real SteamID64 is 17. - const steamIds = Array.isArray(params.steamIds) - ? [ - ...new Set( - params.steamIds - .map((steamId) => String(steamId)) - .filter((steamId) => /^\d{1,18}$/.test(steamId)), - ), - ].slice(0, SanctionsService.MAX_SYNC_STEAM_IDS) - : []; + const steamIds = SanctionsService.syncSteamIds(params.steamIds); if (steamIds.length === 0) { return []; diff --git a/test/server-access.spec.ts b/test/server-access.spec.ts new file mode 100644 index 00000000..df313585 --- /dev/null +++ b/test/server-access.spec.ts @@ -0,0 +1,366 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { ServerAccessService } from "./../src/dedicated-servers/server-access.service"; +import { DedicatedServerConfigService } from "./../src/dedicated-servers/dedicated-server-config.service"; +import { bootMigratedDb, runAsUser, SqlTestDb } from "./utils/sql-test-db"; + +// Who a restricted community server lets in, resolved against the real schema: +// the allowlist the Player Management plugin enforces and the connect info the +// panel shows or hides. +describe("server access (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let access: ServerAccessService; + let config: DedicatedServerConfigService; + + const rconSend = jest.fn(async (): Promise => ""); + + const OWNER = "76561190000000100"; + const PICKED = "76561190000000101"; + const VERIFIED = "76561190000000102"; + const MODERATOR = "76561190000000103"; + const EVENT_PLAYER = "76561190000000104"; + const TEAM_PLAYER = "76561190000000105"; + const STRANGER = "76561190000000106"; + + beforeAll(async () => { + db = await bootMigratedDb("ServerAccess"); + postgres = db.postgres; + access = new ServerAccessService(postgres); + config = new DedicatedServerConfigService( + { warn: jest.fn(), log: jest.fn() } as never, + postgres, + {} as never, + { connect: jest.fn(async () => ({ send: rconSend })) } as never, + ); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + let serverId: string; + + beforeEach(async () => { + rconSend.mockClear(); + + await postgres.query("DELETE FROM servers"); + await postgres.query("DELETE FROM events"); + + const roles: Array<[string, string]> = [ + [OWNER, "user"], + [PICKED, "user"], + [VERIFIED, "verified_user"], + [MODERATOR, "moderator"], + [EVENT_PLAYER, "user"], + [TEAM_PLAYER, "user"], + [STRANGER, "user"], + ]; + + for (const [steamId, role] of roles) { + await postgres.query( + `INSERT INTO players (steam_id, name, role) VALUES ($1::bigint, $1::text, $2) + ON CONFLICT (steam_id) DO UPDATE SET role = EXCLUDED.role`, + [steamId, role], + ); + } + + await postgres.query( + `INSERT INTO server_regions (value, description, is_lan) + VALUES ('TestRegion', 'TestRegion', true) ON CONFLICT (value) DO NOTHING`, + ); + + const [server] = await postgres.query>( + `INSERT INTO servers + (host, label, rcon_password, port, tv_port, region, type, is_dedicated, + enabled, connected) + VALUES ('10.0.0.1', 'prophunt', $1, 27015, 27020, 'TestRegion', 'Casual', + true, true, true) + RETURNING id`, + [Buffer.from("password")], + ); + + serverId = server.id; + }); + + // Rosters can't be torn down between tests (an owner may not leave their + // team), so one team is built once and only ever grows. + let squadId: string | undefined; + + const squad = async (steamIds: Array): Promise => { + if (!squadId) { + const [team] = await postgres.query>( + `INSERT INTO teams (name, short_name, owner_steam_id) + VALUES ('Squad', 'SQD', $1) RETURNING id`, + [OWNER], + ); + squadId = team.id; + } + + for (const steamId of steamIds) { + await runAsUser(postgres, OWNER, "admin", (query) => + query( + `INSERT INTO team_roster (team_id, player_steam_id, status) + VALUES ($1, $2, 'Starter') ON CONFLICT DO NOTHING`, + [squadId, steamId], + ), + ); + } + + return squadId; + }; + + const allowed = async (): Promise> => + (await access.allowlist(serverId)).steamIds; + + const event = async ( + window: { starts: string | null; ends: string | null }, + members: { players?: Array; teamPlayers?: Array } = {}, + ): Promise => { + const [row] = await postgres.query>( + `INSERT INTO events (name, organizer_steam_id, starts_at, ends_at) + VALUES ('LAN', $1, now() + $2::interval, now() + $3::interval) + RETURNING id`, + [OWNER, window.starts ?? "-100 years", window.ends ?? "100 years"], + ); + + if (window.starts === null || window.ends === null) { + await postgres.query( + `UPDATE events + SET starts_at = CASE WHEN $2 THEN NULL ELSE starts_at END, + ends_at = CASE WHEN $3 THEN NULL ELSE ends_at END + WHERE id = $1`, + [row.id, window.starts === null, window.ends === null], + ); + } + + for (const steamId of members.players ?? []) { + await postgres.query( + `INSERT INTO event_players (event_id, steam_id) VALUES ($1, $2)`, + [row.id, steamId], + ); + } + + if (members.teamPlayers?.length) { + await postgres.query( + `INSERT INTO event_teams (event_id, team_id) VALUES ($1, $2)`, + [row.id, await squad(members.teamPlayers)], + ); + } + + return row.id; + }; + + const restrict = async ( + rules: { + minRole?: string | null; + steamIds?: Array; + eventIds?: Array; + } = {}, + ): Promise => { + await config.setAccess(serverId, { + restricted: true, + minRole: rules.minRole ?? null, + steamIds: rules.steamIds ?? [], + eventIds: rules.eventIds ?? [], + }); + }; + + const connectionFor = async ( + session: Record, + ): Promise => { + const [row] = await postgres.query>( + `SELECT get_server_connection_string(s, $2::json) AS value + FROM servers s WHERE s.id = $1`, + [serverId, JSON.stringify(session)], + ); + + return row.value; + }; + + const guest = { "x-hasura-role": "guest", "x-hasura-user-id": "0" }; + const user = (steamId: string, role = "user") => ({ + "x-hasura-role": role, + "x-hasura-user-id": steamId, + }); + + describe("the allowlist", () => { + it("is open until the server is restricted", async () => { + expect(await access.allowlist(serverId)).toEqual({ + restricted: false, + version: "open", + steamIds: [], + }); + }); + + it("lets in picked players and always staff", async () => { + await restrict({ steamIds: [PICKED] }); + + expect(await allowed()).toEqual( + expect.arrayContaining([PICKED, MODERATOR]), + ); + expect(await allowed()).not.toContain(STRANGER); + }); + + it("lets in everyone at or above the minimum role", async () => { + await restrict({ minRole: "verified_user" }); + + expect(await allowed()).toEqual( + expect.arrayContaining([VERIFIED, MODERATOR]), + ); + expect(await allowed()).not.toContain(PICKED); + }); + + it("lets in members of an event while it runs", async () => { + const running = await event( + { starts: "-1 hour", ends: "1 hour" }, + { players: [EVENT_PLAYER], teamPlayers: [TEAM_PLAYER] }, + ); + await restrict({ eventIds: [running] }); + + expect(await allowed()).toEqual( + expect.arrayContaining([OWNER, EVENT_PLAYER, TEAM_PLAYER]), + ); + expect(await allowed()).not.toContain(STRANGER); + }); + + it("lets nobody in through an event that has not started or has ended", async () => { + const upcoming = await event( + { starts: "1 day", ends: "2 days" }, + { players: [EVENT_PLAYER] }, + ); + const over = await event( + { starts: "-2 days", ends: "-1 day" }, + { players: [TEAM_PLAYER] }, + ); + await restrict({ eventIds: [upcoming, over] }); + + expect(await allowed()).not.toContain(EVENT_PLAYER); + expect(await allowed()).not.toContain(TEAM_PLAYER); + }); + + it("keeps an event with no end date running", async () => { + const openEnded = await event( + { starts: "-1 hour", ends: null }, + { players: [EVENT_PLAYER] }, + ); + await restrict({ eventIds: [openEnded] }); + + expect(await allowed()).toContain(EVENT_PLAYER); + }); + }); + + describe("the sync", () => { + it("names the present players who are not allowed", async () => { + await restrict({ steamIds: [PICKED] }); + + const sync = await access.forSync(serverId, [PICKED, STRANGER]); + + expect(sync.restricted).toBe(true); + expect(sync.denied).toEqual([STRANGER]); + }); + + // An event ending changes who is allowed without anything being saved, + // so the version has to follow the resolved list, not the last write. + it("changes version when the allowed players change, and only then", async () => { + const running = await event( + { starts: "-1 hour", ends: "1 hour" }, + { players: [EVENT_PLAYER] }, + ); + await restrict({ eventIds: [running] }); + + const before = (await access.forSync(serverId, [])).version; + expect((await access.forSync(serverId, [])).version).toEqual(before); + + await postgres.query( + `UPDATE events SET ends_at = now() - interval '1 minute' WHERE id = $1`, + [running], + ); + + expect((await access.forSync(serverId, [])).version).not.toEqual(before); + }); + + it("denies nobody on an open server", async () => { + expect(await access.forSync(serverId, [STRANGER])).toEqual({ + restricted: false, + version: "open", + denied: [], + message: null, + }); + }); + }); + + describe("the connect info the panel shows", () => { + it("hides a restricted server from guests and players who are not allowed", async () => { + await restrict({ steamIds: [PICKED] }); + + expect(await connectionFor(guest)).toBeNull(); + expect(await connectionFor(user(STRANGER))).toBeNull(); + }); + + it("shows it to allowed players and staff", async () => { + const running = await event( + { starts: "-1 hour", ends: "1 hour" }, + { players: [EVENT_PLAYER] }, + ); + await restrict({ + steamIds: [PICKED], + minRole: "verified_user", + eventIds: [running], + }); + + for (const session of [ + user(PICKED), + user(VERIFIED, "verified_user"), + user(EVENT_PLAYER), + user(MODERATOR, "moderator"), + ]) { + expect(await connectionFor(session)).toEqual("connect 10.0.0.1:27015"); + } + }); + + it("shows an open server to everyone", async () => { + expect(await connectionFor(guest)).toEqual("connect 10.0.0.1:27015"); + }); + }); + + describe("saving", () => { + it("replaces the rules and tells the plugin to resync", async () => { + await restrict({ steamIds: [PICKED, STRANGER] }); + await config.setAccess(serverId, { + restricted: true, + minRole: null, + steamIds: [PICKED], + eventIds: [], + }); + + expect(await allowed()).not.toContain(STRANGER); + expect(rconSend).toHaveBeenLastCalledWith("player_management_refresh"); + }); + + it("opens the server again when unrestricted", async () => { + await restrict({ steamIds: [PICKED] }); + await config.setAccess(serverId, { + restricted: false, + minRole: null, + steamIds: [], + eventIds: [], + }); + + expect((await access.allowlist(serverId)).restricted).toBe(false); + expect(await connectionFor(guest)).toEqual("connect 10.0.0.1:27015"); + }); + + it("refuses Ranked and CS:GO servers", async () => { + await postgres.query(`UPDATE servers SET type = 'Ranked' WHERE id = $1`, [ + serverId, + ]); + await expect(restrict()).rejects.toThrow(/Ranked servers/); + + await postgres.query( + `UPDATE servers SET type = 'Casual', game = 'csgo' WHERE id = $1`, + [serverId], + ); + await expect(restrict()).rejects.toThrow(/CS2 only/); + }); + }); +}); diff --git a/test/server-map-rotation.spec.ts b/test/server-map-rotation.spec.ts index 6d05e6b2..4dcda73f 100644 --- a/test/server-map-rotation.spec.ts +++ b/test/server-map-rotation.spec.ts @@ -21,6 +21,7 @@ describe("dedicated server config (SQL-driven)", () => { { warn: jest.fn(), log: jest.fn() } as never, postgres, dedicatedServers as never, + { connect: jest.fn(async (): Promise => null) } as never, ); }, 600_000); From d334ba34c8a7cef0444407a37c3e2f55a70a3484 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Thu, 1 Oct 2026 08:59:12 -0400 Subject: [PATCH 2/2] feature: save a community server's settings in one call with one restart (#473) --- hasura/metadata/actions.graphql | 21 +++++++++ hasura/metadata/actions.yaml | 10 ++++ .../dedicated-server-config.service.ts | 47 ++++++++++++++++++- .../dedicated-servers.controller.ts | 36 ++++++++++++++ test/server-map-rotation.spec.ts | 41 ++++++++++++++++ 5 files changed, 153 insertions(+), 2 deletions(-) diff --git a/hasura/metadata/actions.graphql b/hasura/metadata/actions.graphql index fa0bd09d..487b3087 100644 --- a/hasura/metadata/actions.graphql +++ b/hasura/metadata/actions.graphql @@ -2684,3 +2684,24 @@ type Mutation { event_ids: [uuid!]! ): SuccessOutput } + +type Mutation { + setServerSettings( + server_id: uuid! + map_rotation: ServerMapRotationInput + plugins: [ServerPluginInput!] + access: ServerAccessInput + ): SuccessOutput +} + +input ServerMapRotationInput { + map_ids: [uuid!]! + shuffle: Boolean! +} + +input ServerAccessInput { + restricted: Boolean! + min_role: String + steam_ids: [String!]! + event_ids: [uuid!]! +} diff --git a/hasura/metadata/actions.yaml b/hasura/metadata/actions.yaml index 78489f63..528cbbfa 100644 --- a/hasura/metadata/actions.yaml +++ b/hasura/metadata/actions.yaml @@ -2030,6 +2030,14 @@ actions: permissions: - role: administrator comment: Restrict who may connect to a community server + - name: setServerSettings + definition: + kind: synchronous + handler: '{{HASURA_GRAPHQL_ACTIONS_HOOK}}' + forward_client_headers: true + permissions: + - role: administrator + comment: Save a community server's rotation, plugins and access, restarting it at most once custom_types: enums: [] input_objects: @@ -2042,6 +2050,8 @@ custom_types: - name: UtilityPlaybookStepInput - name: UtilitySightlinePairInput - name: ServerPluginInput + - name: ServerMapRotationInput + - name: ServerAccessInput objects: - name: Award - name: AwardRecipient diff --git a/src/dedicated-servers/dedicated-server-config.service.ts b/src/dedicated-servers/dedicated-server-config.service.ts index 970698e9..2dadd9c1 100644 --- a/src/dedicated-servers/dedicated-server-config.service.ts +++ b/src/dedicated-servers/dedicated-server-config.service.ts @@ -38,10 +38,48 @@ export class DedicatedServerConfigService { private readonly rcon: RconService, ) {} + // One save from the settings console: the pod settings are written first and + // the server restarts once for all of them, while access applies live. + public async saveSettings( + serverId: string, + settings: { + mapRotation: { mapIds: Array; shuffle: boolean } | null; + plugins: Array<{ slug: string; enabled: boolean }> | null; + access: { + restricted: boolean; + minRole: string | null; + steamIds: Array; + eventIds: Array; + } | null; + }, + ): Promise { + if (settings.mapRotation) { + await this.setMapRotation( + serverId, + settings.mapRotation.mapIds, + settings.mapRotation.shuffle, + { restart: false }, + ); + } + + if (settings.plugins) { + await this.setPlugins(serverId, settings.plugins, { restart: false }); + } + + if (settings.access) { + await this.setAccess(serverId, settings.access); + } + + if (settings.mapRotation || settings.plugins) { + await this.restart(await this.communityServer(serverId)); + } + } + public async setMapRotation( serverId: string, mapIds: Array, shuffle: boolean, + options: { restart?: boolean } = {}, ): Promise { const server = await this.communityServer(serverId); @@ -89,12 +127,15 @@ export class DedicatedServerConfigService { ); }); - await this.restart(server); + if (options.restart !== false) { + await this.restart(server); + } } public async setPlugins( serverId: string, plugins: Array<{ slug: string; enabled: boolean }>, + options: { restart?: boolean } = {}, ): Promise { const server = await this.communityServer(serverId); @@ -115,7 +156,9 @@ export class DedicatedServerConfigService { ); }); - await this.restart(server); + if (options.restart !== false) { + await this.restart(server); + } } // Enforced by the Player Management plugin, which rereads the list on a diff --git a/src/dedicated-servers/dedicated-servers.controller.ts b/src/dedicated-servers/dedicated-servers.controller.ts index 828fab95..603644fb 100644 --- a/src/dedicated-servers/dedicated-servers.controller.ts +++ b/src/dedicated-servers/dedicated-servers.controller.ts @@ -142,6 +142,42 @@ export class DedicatedServersController { })); } + @HasuraAction() + public async setServerSettings(data: { + user: User; + server_id: string; + map_rotation?: { map_ids: Array; shuffle: boolean } | null; + plugins?: Array<{ slug: string; enabled: boolean }> | null; + access?: { + restricted: boolean; + min_role?: string | null; + steam_ids: Array; + event_ids: Array; + } | null; + }) { + this.assertAdministrator(data.user); + + await this.dedicatedServerConfig.saveSettings(data.server_id, { + mapRotation: data.map_rotation + ? { + mapIds: data.map_rotation.map_ids, + shuffle: data.map_rotation.shuffle, + } + : null, + plugins: data.plugins ?? null, + access: data.access + ? { + restricted: data.access.restricted, + minRole: data.access.min_role ?? null, + steamIds: data.access.steam_ids, + eventIds: data.access.event_ids, + } + : null, + }); + + return { success: true }; + } + @HasuraAction() public async setServerMapRotation(data: { user: User; diff --git a/test/server-map-rotation.spec.ts b/test/server-map-rotation.spec.ts index 4dcda73f..6d7f8f42 100644 --- a/test/server-map-rotation.spec.ts +++ b/test/server-map-rotation.spec.ts @@ -178,6 +178,47 @@ describe("dedicated server config (SQL-driven)", () => { }); }); + describe("saveSettings", () => { + beforeEach(async () => { + await postgres.query( + `INSERT INTO game_plugins (slug, kind, name, author, description) + VALUES ('csroll', 'game', 'csroll', 'tester', 'a test plugin') + ON CONFLICT (slug) DO NOTHING`, + ); + await postgres.query( + `INSERT INTO game_plugin_installs (plugin_slug, version, channel) + VALUES ('csroll', NULL, 'Auto') ON CONFLICT (plugin_slug) DO NOTHING`, + ); + }); + + // Saving the rotation and the plugins used to be two saves, each of + // which restarted the server. + it("restarts the server once for rotation and plugin changes together", async () => { + await service.saveSettings(serverId, { + mapRotation: { mapIds: [await map("rotation-a")], shuffle: false }, + plugins: [{ slug: "csroll", enabled: true }], + access: null, + }); + + expect(await rotation()).toHaveLength(1); + expect(dedicatedServers.rebuildDedicatedServer).toHaveBeenCalledTimes(1); + }); + + it("does not restart for an access change alone", async () => { + await service.saveSettings(serverId, { + mapRotation: null, + plugins: null, + access: { restricted: true, minRole: null, steamIds: [], eventIds: [] }, + }); + + const [server] = await postgres.query< + Array<{ access_restricted: boolean }> + >(`SELECT access_restricted FROM servers WHERE id = $1`, [serverId]); + expect(server.access_restricted).toBe(true); + expect(dedicatedServers.rebuildDedicatedServer).not.toHaveBeenCalled(); + }); + }); + describe("setPlugins", () => { beforeEach(async () => { for (const slug of ["csroll", "map-chooser"]) {