diff --git a/hasura/metadata/databases/default/tables/public_servers.yaml b/hasura/metadata/databases/default/tables/public_servers.yaml index 0b000926..db428763 100644 --- a/hasura/metadata/databases/default/tables/public_servers.yaml +++ b/hasura/metadata/databases/default/tables/public_servers.yaml @@ -101,6 +101,9 @@ select_permissions: - map_rotation_shuffle - max_players - offline_at + - player_management_runtime + - player_management_seen_at + - player_management_version - plugin_runtime - plugin_version - port @@ -156,12 +159,17 @@ select_permissions: - connected - enabled - game + - game_server_node_id - host - id - is_dedicated - label - map_rotation_shuffle - max_players + - offline_at + - player_management_runtime + - player_management_seen_at + - player_management_version - plugin_runtime - plugin_version - port diff --git a/hasura/migrations/default/1889000000600_server_player_management/down.sql b/hasura/migrations/default/1889000000600_server_player_management/down.sql new file mode 100644 index 00000000..c5e1e3a0 --- /dev/null +++ b/hasura/migrations/default/1889000000600_server_player_management/down.sql @@ -0,0 +1,4 @@ +ALTER TABLE public.servers + DROP COLUMN IF EXISTS player_management_seen_at, + DROP COLUMN IF EXISTS player_management_runtime, + DROP COLUMN IF EXISTS player_management_version; diff --git a/hasura/migrations/default/1889000000600_server_player_management/up.sql b/hasura/migrations/default/1889000000600_server_player_management/up.sql new file mode 100644 index 00000000..7e56b54a --- /dev/null +++ b/hasura/migrations/default/1889000000600_server_player_management/up.sql @@ -0,0 +1,4 @@ +ALTER TABLE public.servers + ADD COLUMN IF NOT EXISTS player_management_version text, + ADD COLUMN IF NOT EXISTS player_management_runtime text, + ADD COLUMN IF NOT EXISTS player_management_seen_at timestamptz; diff --git a/src/dedicated-servers/dedicated-servers.service.spec.ts b/src/dedicated-servers/dedicated-servers.service.spec.ts index b01aaf71..a63e2894 100644 --- a/src/dedicated-servers/dedicated-servers.service.spec.ts +++ b/src/dedicated-servers/dedicated-servers.service.spec.ts @@ -84,3 +84,45 @@ describe("DedicatedServersService.rebuildDedicatedServer", () => { expect(second).toEqual({ status: "fulfilled", value: true }); }); }); + +describe("DedicatedServersService.pluginInstallEnvironment", () => { + const installs = (type: string, game = "cs2") => + Object.fromEntries( + DedicatedServersService.pluginInstallEnvironment({ type, game }).map( + ({ name, value }) => [name, value], + ), + ); + + it("gives a Ranked server only the match plugin", () => { + expect(installs("Ranked")).toEqual({ + INSTALL_5STACK_PLUGIN: "true", + INSTALL_UTILITY_PRACTICE_PLUGIN: "false", + INSTALL_PLAYER_MANAGEMENT_PLUGIN: "false", + }); + }); + + it("gives a Practice server only the utility plugin", () => { + expect(installs("Practice")).toEqual({ + INSTALL_5STACK_PLUGIN: "false", + INSTALL_UTILITY_PRACTICE_PLUGIN: "true", + INSTALL_PLAYER_MANAGEMENT_PLUGIN: "false", + }); + }); + + it.each(["Competitive", "Casual", "Wingman", "Deathmatch", "Custom"])( + "gives a %s community server the player management plugin", + (type) => { + expect(installs(type)).toEqual({ + INSTALL_5STACK_PLUGIN: "false", + INSTALL_UTILITY_PRACTICE_PLUGIN: "false", + INSTALL_PLAYER_MANAGEMENT_PLUGIN: "true", + }); + }, + ); + + it("never gives a CS:GO server the CS2-only player management plugin", () => { + expect(installs("Casual", "csgo").INSTALL_PLAYER_MANAGEMENT_PLUGIN).toBe( + "false", + ); + }); +}); diff --git a/src/dedicated-servers/dedicated-servers.service.ts b/src/dedicated-servers/dedicated-servers.service.ts index 370e4276..3fe80166 100644 --- a/src/dedicated-servers/dedicated-servers.service.ts +++ b/src/dedicated-servers/dedicated-servers.service.ts @@ -53,6 +53,31 @@ export class DedicatedServersService { this.apps = kc.makeApiClient(AppsV1Api); } + // Exactly one 5stack plugin per server: the match plugin on Ranked, the + // utility plugin on Practice, and on a community server, which has no match + // plugin to carry sanctions, the player management plugin. It is CS2 only. + public static pluginInstallEnvironment(server: { + type: string; + game: string; + }): Array<{ name: string; value: string }> { + const community = server.type !== "Ranked" && server.type !== "Practice"; + + return [ + { + name: "INSTALL_5STACK_PLUGIN", + value: server.type === "Ranked" ? "true" : "false", + }, + { + name: "INSTALL_UTILITY_PRACTICE_PLUGIN", + value: server.type === "Practice" ? "true" : "false", + }, + { + name: "INSTALL_PLAYER_MANAGEMENT_PLUGIN", + value: community && server.game !== "csgo" ? "true" : "false", + }, + ]; + } + public async setupDedicatedServer(serverId: string): Promise { this.logger.log(`[${serverId}] assigning dedicated server`); @@ -255,16 +280,9 @@ export class DedicatedServersService { name: "SERVER_TYPE", value: server.type, }, - { - name: "INSTALL_5STACK_PLUGIN", - value: server.type === "Ranked" ? "true" : "false", - }, - // A practice server runs the utility plugin in the match - // plugin's place -- never both, and never neither. - { - name: "INSTALL_UTILITY_PRACTICE_PLUGIN", - value: server.type === "Practice" ? "true" : "false", - }, + ...DedicatedServersService.pluginInstallEnvironment( + server, + ), { name: "GAME_NODE_SERVER", value: "true", diff --git a/src/sanctions/sanctions.controller.spec.ts b/src/sanctions/sanctions.controller.spec.ts new file mode 100644 index 00000000..9940c8d6 --- /dev/null +++ b/src/sanctions/sanctions.controller.spec.ts @@ -0,0 +1,63 @@ +import { ForbiddenException, RequestMethod } from "@nestjs/common"; +import { MatchServerMiddlewareMiddleware } from "src/matches/match-server-middleware/match-server-middleware.middleware"; +import { SanctionsController } from "./sanctions.controller"; +import { SanctionsModule } from "./sanctions.module"; + +describe("SanctionsController.syncServerSanctions", () => { + const serverA = "11111111-1111-1111-1111-111111111111"; + const serverB = "22222222-2222-2222-2222-222222222222"; + + let syncServerSanctions: jest.Mock; + let controller: SanctionsController; + + beforeEach(() => { + syncServerSanctions = jest.fn().mockResolvedValue([]); + controller = new SanctionsController({ syncServerSanctions } as any); + }); + + // The middleware authenticates the body's serverId in preference to the + // path's, so a server could otherwise prove itself as A and write B. + it("refuses a body serverId that is not the server in the path", async () => { + await expect( + controller.syncServerSanctions(serverB, { + serverId: serverA, + plugin_version: "9.9.9", + }), + ).rejects.toThrow(ForbiddenException); + + expect(syncServerSanctions).not.toHaveBeenCalled(); + }); + + it("syncs the server in the path", async () => { + await expect( + controller.syncServerSanctions(serverA, { + steam_ids: ["76561198000000001"], + plugin_version: "0.0.412", + plugin_runtime: "swiftlys2", + }), + ).resolves.toEqual({ sanctions: [] }); + + expect(syncServerSanctions).toHaveBeenCalledWith(serverA, { + steamIds: ["76561198000000001"], + pluginVersion: "0.0.412", + pluginRuntime: "swiftlys2", + }); + }); +}); + +describe("SanctionsModule", () => { + // Without the middleware on this exact route and method the endpoint would + // answer anyone and record anyone's heartbeat. + it("puts the sync route behind the game server's api password", () => { + const forRoutes = jest.fn(); + const apply = jest.fn(() => ({ forRoutes })); + + new SanctionsModule().configure({ apply } as any); + + expect(apply).toHaveBeenCalledWith(MatchServerMiddlewareMiddleware); + expect(forRoutes).toHaveBeenCalledWith({ + path: "sanctions/server/:serverId", + method: RequestMethod.POST, + }); + }); +}); diff --git a/src/sanctions/sanctions.controller.ts b/src/sanctions/sanctions.controller.ts index 5ef2fc5c..7eb8474c 100644 --- a/src/sanctions/sanctions.controller.ts +++ b/src/sanctions/sanctions.controller.ts @@ -1,4 +1,11 @@ -import { Controller, Get, Param } from "@nestjs/common"; +import { + Body, + Controller, + ForbiddenException, + HttpCode, + Param, + Post, +} from "@nestjs/common"; import { HasuraAction } from "src/hasura/hasura.controller"; import { User } from "src/auth/types/User"; import { isRoleAbove } from "src/utilities/isRoleAbove"; @@ -9,10 +16,32 @@ import { SanctionType } from "./sanction-types"; export class SanctionsController { constructor(private readonly sanctionsService: SanctionsService) {} - @Get("server/:serverId") - public async serverSanctions(@Param("serverId") serverId: string) { + // Polled by the game-server Player Management plugin; the call doubles as + // its heartbeat. + @Post("server/:serverId") + @HttpCode(200) + public async syncServerSanctions( + @Param("serverId") serverId: string, + @Body() + body: { + serverId?: unknown; + steam_ids?: unknown; + plugin_version?: unknown; + plugin_runtime?: unknown; + }, + ) { + // The server middleware authenticates a body serverId ahead of the path + // one, so without this any server could write another's heartbeat. + if (body?.serverId !== undefined && body.serverId !== serverId) { + throw new ForbiddenException(); + } + return { - sanctions: await this.sanctionsService.getActiveServerSanctions(serverId), + sanctions: await this.sanctionsService.syncServerSanctions(serverId, { + steamIds: body?.steam_ids, + pluginVersion: body?.plugin_version, + pluginRuntime: body?.plugin_runtime, + }), }; } diff --git a/src/sanctions/sanctions.module.ts b/src/sanctions/sanctions.module.ts index 6854eac8..c5420766 100644 --- a/src/sanctions/sanctions.module.ts +++ b/src/sanctions/sanctions.module.ts @@ -24,7 +24,7 @@ export class SanctionsModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer.apply(MatchServerMiddlewareMiddleware).forRoutes({ path: "sanctions/server/:serverId", - method: RequestMethod.GET, + method: RequestMethod.POST, }); } } diff --git a/src/sanctions/sanctions.service.spec.ts b/src/sanctions/sanctions.service.spec.ts index 8d66953a..8c1c2507 100644 --- a/src/sanctions/sanctions.service.spec.ts +++ b/src/sanctions/sanctions.service.spec.ts @@ -119,29 +119,284 @@ describe("SanctionsService", () => { }); }); - describe("getActiveServerSanctions", () => { - it("only reads the types a server enforces", async () => { - await service.getActiveServerSanctions("server-1"); + describe("syncServerSanctions", () => { + const heartbeats = () => + postgres.query.mock.calls.filter(([sql]) => + sql.includes("UPDATE public.servers"), + ); + const reads = () => + postgres.query.mock.calls.filter(([sql]) => + sql.includes("FROM public.player_sanctions"), + ); - const [sql, params] = postgres.query.mock.calls[0]; + it("reads only the enforced types, for only the players it was asked about", async () => { + await service.syncServerSanctions("server-1", { + steamIds: [steamId, "76561198000000002"], + }); + + const [sql, params] = reads()[0]; expect(sql).toContain("type = ANY($1::text[])"); - expect(params).toEqual([["ban", "mute", "gag", "silence"]]); - expect(params[0]).not.toContain("warning"); + expect(sql).toContain("player_steam_id = ANY($2::bigint[])"); + expect(params).toEqual([ + ["ban", "mute", "gag", "silence"], + [steamId, "76561198000000002"], + ]); }); - it("maps the enforced types onto the plugin flags", async () => { - postgres.query.mockResolvedValueOnce([ - { player_steam_id: "1", type: "ban" }, - { player_steam_id: "2", type: "silence" }, - { player_steam_id: "3", type: "mute" }, - ]); + it("drops anything that is not a steam id, and duplicates", async () => { + await service.syncServerSanctions("server-1", { + steamIds: [steamId, steamId, "1; DROP TABLE players", "9".repeat(19)], + }); + + expect(reads()[0][1][1]).toEqual([steamId]); + }); + + it("hands the plugin each sanction with an ISO expiry", async () => { + postgres.query.mockImplementation(async (sql: string) => + sql.includes("FROM public.player_sanctions") + ? [ + { + steam_id: steamId, + type: "silence", + reason: "spam", + expires_at: new Date("2026-10-01T00:00:00Z"), + }, + { + steam_id: steamId, + type: "ban", + reason: null, + expires_at: null, + }, + ] + : [], + ); - expect(await service.getActiveServerSanctions("server-1")).toEqual([ - { steam_id: "1", is_banned: true, is_muted: false, is_gagged: false }, - { steam_id: "2", is_banned: false, is_muted: true, is_gagged: true }, - { steam_id: "3", is_banned: false, is_muted: true, is_gagged: false }, + expect( + await service.syncServerSanctions("server-1", { steamIds: [steamId] }), + ).toEqual([ + { + steam_id: steamId, + type: "silence", + reason: "spam", + expires_at: "2026-10-01T00:00:00.000Z", + }, + { steam_id: steamId, type: "ban", reason: null, expires_at: null }, ]); }); + + // An empty server still has to show as running the plugin. + it("records the heartbeat of an empty server without reading sanctions", async () => { + expect( + await service.syncServerSanctions("server-1", { + steamIds: [], + pluginVersion: "0.0.412", + pluginRuntime: "swiftlys2", + }), + ).toEqual([]); + + expect(reads()).toHaveLength(0); + const [sql, params] = heartbeats()[0]; + expect(sql).toContain("player_management_seen_at = now()"); + expect(sql).toContain("interval '60 seconds'"); + expect(params).toEqual(["server-1", "0.0.412", "swiftlys2"]); + }); + + it("records a dev build as dev and an unknown runtime as nothing", async () => { + await service.syncServerSanctions("server-1", { + pluginVersion: "__RELEASE_VERSION__", + pluginRuntime: "metamod", + }); + + expect(heartbeats()[0][1]).toEqual(["server-1", "dev", null]); + }); + + it("still answers when the heartbeat cannot be written", async () => { + postgres.query.mockImplementation(async (sql: string) => { + if (sql.includes("UPDATE public.servers")) { + throw new Error("deadlock"); + } + return []; + }); + + await expect( + service.syncServerSanctions("server-1", { steamIds: [steamId] }), + ).resolves.toEqual([]); + }); + }); + + describe("syncing a community server", () => { + const replyToRefresh = (reply: string) => { + rcon.send.mockImplementation(async (command: string) => + command === "player_management_refresh" ? reply : "", + ); + }; + + beforeEach(() => { + hasura.query.mockResolvedValue({ + matches: [], + servers_by_pk: { is_dedicated: true, type: "Casual", game: "cs2" }, + }); + }); + + it("asks the player management plugin to sync instead of the match plugin", async () => { + replyToRefresh("PlayerManagement: syncing 4 player(s)"); + + const result = await service.sanctionServerPlayer({ + serverId: "server-1", + steamId, + type: "gag", + sanctionedBySteamId: moderator, + }); + + expect(rcon.send).toHaveBeenCalledWith("player_management_refresh"); + expect(rcon.send).not.toHaveBeenCalledWith("get_match"); + expect(result).toMatchObject({ + enforced: true, + message: "sanction saved and synced to server", + }); + }); + + it("says so when the plugin is loaded but not configured", async () => { + replyToRefresh( + "PlayerManagement: not configured; set API_DOMAIN, SERVER_ID and SERVER_API_PASSWORD", + ); + + const result = await service.sanctionServerPlayer({ + serverId: "server-1", + steamId, + type: "mute", + sanctionedBySteamId: moderator, + }); + + expect(result).toMatchObject({ + enforced: false, + message: + "sanction saved; the Player Management plugin on this server is not configured", + }); + }); + + it("says so when the plugin is not installed", async () => { + replyToRefresh('Unknown command "player_management_refresh"!'); + + const result = await service.sanctionServerPlayer({ + serverId: "server-1", + steamId, + type: "mute", + sanctionedBySteamId: moderator, + }); + + expect(result).toMatchObject({ + enforced: false, + message: + "sanction saved; the Player Management plugin is not installed on this server", + }); + }); + + // The kick lands, but nothing stops the player rejoining, so the + // moderator is told rather than shown a clean success. + it("does not count a kicked ban as enforced when the plugin is missing", async () => { + replyToRefresh(""); + + const result = await service.sanctionServerPlayer({ + serverId: "server-1", + steamId, + type: "ban", + sanctionedBySteamId: moderator, + }); + + expect(rcon.send).toHaveBeenCalledWith("kickid 4 Banned"); + expect(result).toMatchObject({ + enforced: false, + message: + "sanction saved and player kicked; the Player Management plugin is not installed on this server", + }); + }); + + it("keeps the kick in the message when the plugin is not configured", async () => { + replyToRefresh("PlayerManagement: not configured"); + + const result = await service.sanctionServerPlayer({ + serverId: "server-1", + steamId, + type: "ban", + sanctionedBySteamId: moderator, + }); + + expect(result).toMatchObject({ + enforced: false, + message: + "sanction saved and player kicked; the Player Management plugin on this server is not configured", + }); + }); + + it("words a lifted sanction as removed, not saved", async () => { + replyToRefresh(""); + + const result = await service.unsanctionServerPlayer({ + serverId: "server-1", + steamId, + type: "mute", + }); + + expect(result).toMatchObject({ + enforced: false, + message: + "sanction removed; the Player Management plugin is not installed on this server", + }); + }); + + it("never asks a CS:GO server for the CS2-only plugin", async () => { + hasura.query.mockResolvedValue({ + matches: [], + servers_by_pk: { is_dedicated: true, type: "Casual", game: "csgo" }, + }); + + const result = await service.sanctionServerPlayer({ + serverId: "server-1", + steamId, + type: "mute", + sanctionedBySteamId: moderator, + }); + + expect(rcon.send).not.toHaveBeenCalledWith("player_management_refresh"); + expect(result).toMatchObject({ + enforced: false, + message: "sanction saved; server has no match to sync", + }); + }); + + it("refreshes the plugin when a sanction is lifted", async () => { + replyToRefresh("PlayerManagement: syncing 1 player(s)"); + + const result = await service.unsanctionServerPlayer({ + serverId: "server-1", + steamId, + type: "gag", + }); + + expect(rcon.send).toHaveBeenCalledWith("player_management_refresh"); + expect(result.enforced).toBe(true); + }); + + it("leaves a Ranked server with no match alone", async () => { + hasura.query.mockResolvedValue({ + matches: [], + servers_by_pk: { is_dedicated: true, type: "Ranked", game: "cs2" }, + }); + + const result = await service.sanctionServerPlayer({ + serverId: "server-1", + steamId, + type: "mute", + sanctionedBySteamId: moderator, + }); + + expect(rcon.send).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + enforced: false, + message: "sanction saved; server has no match to sync", + }); + }); }); describe("unsanctionServerPlayer", () => { diff --git a/src/sanctions/sanctions.service.ts b/src/sanctions/sanctions.service.ts index 22bfe0f9..631088a3 100644 --- a/src/sanctions/sanctions.service.ts +++ b/src/sanctions/sanctions.service.ts @@ -15,6 +15,13 @@ export class SanctionsService { private readonly dedicatedServersService: DedicatedServersService, ) {} + private static readonly MAX_SYNC_STEAM_IDS = 256; + + private static readonly PLAYER_MANAGEMENT_RUNTIMES = [ + "swiftlys2", + "counterstrikesharp", + ]; + private static readonly SANCTION_TYPES: SanctionType[] = [ "ban", "mute", @@ -23,52 +30,71 @@ export class SanctionsService { "warning", ]; - public async getActiveServerSanctions(serverId: string): Promise< + public async syncServerSanctions( + serverId: string, + params: { + steamIds?: unknown; + pluginVersion?: unknown; + pluginRuntime?: unknown; + }, + ): Promise< Array<{ steam_id: string; - is_banned: boolean; - is_muted: boolean; - is_gagged: boolean; + type: SanctionType; + reason: string | null; + expires_at: string | null; }> > { - const player_sanctions = await this.postgres.query< - Array<{ player_steam_id: string; type: string }> + await this.recordPlayerManagement( + serverId, + params.pluginVersion, + 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) + : []; + + if (steamIds.length === 0) { + return []; + } + + const sanctions = await this.postgres.query< + Array<{ + steam_id: string; + type: SanctionType; + reason: string | null; + expires_at: Date | null; + }> >( - `SELECT player_steam_id::text AS player_steam_id, type + `SELECT player_steam_id::text AS steam_id, + type, + reason, + remove_sanction_date AS expires_at FROM public.player_sanctions WHERE deleted_at IS NULL AND type = ANY($1::text[]) + AND player_steam_id = ANY($2::bigint[]) AND (remove_sanction_date IS NULL OR remove_sanction_date > now())`, - [SERVER_ENFORCED_SANCTION_TYPES], + [SERVER_ENFORCED_SANCTION_TYPES, steamIds], ); - const byPlayer: Record< - string, - { steam_id: string; is_banned: boolean; is_muted: boolean; is_gagged: boolean } - > = {}; - - for (const sanction of player_sanctions) { - const steamId = `${sanction.player_steam_id}`; - const entry = (byPlayer[steamId] = byPlayer[steamId] || { - steam_id: steamId, - is_banned: false, - is_muted: false, - is_gagged: false, - }); - - if (sanction.type === "ban") { - entry.is_banned = true; - } else if (sanction.type === "mute") { - entry.is_muted = true; - } else if (sanction.type === "gag") { - entry.is_gagged = true; - } else if (sanction.type === "silence") { - entry.is_muted = true; - entry.is_gagged = true; - } - } - - return Object.values(byPlayer); + return sanctions.map((sanction) => ({ + steam_id: sanction.steam_id, + type: sanction.type, + reason: sanction.reason ?? null, + expires_at: sanction.expires_at + ? new Date(sanction.expires_at).toISOString() + : null, + })); } public async sanctionServerPlayer(params: { @@ -129,6 +155,7 @@ export class SanctionsService { const result = await this.syncServer( serverId, type === "ban" ? (onServer?.userid ?? null) : null, + "saved", ); enforced = result.enforced; message = result.message; @@ -192,7 +219,7 @@ export class SanctionsService { let message = type === "warning" ? "warning removed" : "sanction removed"; if (serverId && SERVER_ENFORCED_SANCTION_TYPES.includes(type)) { - const result = await this.syncServer(serverId, null); + const result = await this.syncServer(serverId, null, "removed"); enforced = result.enforced; message = result.message; } @@ -296,8 +323,54 @@ export class SanctionsService { }); } - private async hasLiveMatch(serverId: string): Promise { - const { matches } = await this.hasura.query({ + // The plugin syncs every 30 seconds and this throttles the write to one a + // minute, so the panel should treat a heartbeat older than a few minutes as + // the plugin being gone. + private async recordPlayerManagement( + serverId: string, + version: unknown, + runtime: unknown, + ): Promise { + let pluginVersion = + typeof version === "string" && version.trim() + ? version.trim().slice(0, 64) + : null; + + if (pluginVersion === "__RELEASE_VERSION__") { + pluginVersion = "dev"; + } + + const pluginRuntime = + typeof runtime === "string" && + SanctionsService.PLAYER_MANAGEMENT_RUNTIMES.includes(runtime) + ? runtime + : null; + + try { + await this.postgres.query( + `UPDATE public.servers + SET player_management_version = $2, + player_management_runtime = $3, + player_management_seen_at = now() + WHERE id = $1::uuid + AND (player_management_seen_at IS NULL + OR player_management_seen_at < now() - interval '60 seconds' + OR player_management_version IS DISTINCT FROM $2 + OR player_management_runtime IS DISTINCT FROM $3)`, + [serverId, pluginVersion, pluginRuntime], + ); + } catch (error) { + this.logger.warn( + `unable to record the player management heartbeat for ${serverId}`, + error, + ); + } + } + + private async syncTarget( + serverId: string, + ): Promise<"match" | "player-management" | null> { + const { matches, servers_by_pk } = await this.hasura.query({ matches: { __args: { where: { @@ -312,21 +385,46 @@ export class SanctionsService { }, id: true, }, + servers_by_pk: { + __args: { + id: serverId, + }, + is_dedicated: true, + type: true, + game: true, + }, }); - return matches.length > 0; + if (matches.length > 0) { + return "match"; + } + + // The plugin is CS2 only, so a CS:GO server can never have it. + if ( + servers_by_pk?.is_dedicated && + servers_by_pk.game !== "csgo" && + servers_by_pk.type !== "Ranked" && + servers_by_pk.type !== "Practice" + ) { + return "player-management"; + } + + return null; } private async syncServer( serverId: string, kickUserid: string | null, + action: "saved" | "removed", ): Promise<{ enforced: boolean; message: string }> { + const saved = `sanction ${action}`; + try { const rcon = await this.rconService.connect(serverId); if (!rcon) { return { enforced: false, - message: "sanction saved; unable to connect to server rcon", + message: `${saved}; unable to connect to server rcon`, }; } @@ -334,29 +432,55 @@ export class SanctionsService { await rcon.send(`kickid ${kickUserid} Banned`); } - // The plugins carry mute/gag/ban as flags on the match payload, so a - // match refresh is what actually re-applies them live. A server with no - // match has no command to push sanctions to yet. - if (!(await this.hasLiveMatch(serverId))) { + const kicked = kickUserid !== null; + const target = await this.syncTarget(serverId); + + // A match server's plugin carries mute/gag/ban as flags on the match + // payload, so a match refresh is what actually re-applies them live. + if (target === "match") { + await rcon.send("get_match"); + return { - enforced: kickUserid !== null, - message: kickUserid - ? "sanction saved and player kicked; server has no match to sync" - : "sanction saved; server has no match to sync", + enforced: true, + message: `${saved} and synced to server`, }; } - await rcon.send("get_match"); + if (target === "player-management") { + // The plugin's reply is the contract here (PlayerManagementReport in + // game-server): an unknown command means it is not loaded at all. + const reply = await rcon.send("player_management_refresh"); + + if (reply.includes("PlayerManagement: syncing")) { + return { + enforced: true, + message: `${saved} and synced to server`, + }; + } + + // Not enforced even when the kick landed: without the plugin nothing + // stops a banned player rejoining, and the moderator has to know. + const done = kicked ? `${saved} and player kicked` : saved; + + return { + enforced: false, + message: reply.includes("PlayerManagement:") + ? `${done}; the Player Management plugin on this server is not configured` + : `${done}; the Player Management plugin is not installed on this server`, + }; + } return { - enforced: true, - message: "sanction saved and synced to server", + enforced: kicked, + message: kicked + ? `${saved} and player kicked; server has no match to sync` + : `${saved}; server has no match to sync`, }; } catch (error) { this.logger.warn(`failed to sync sanctions to ${serverId}`, error); return { enforced: false, - message: "sanction saved; live enforcement failed", + message: `${saved}; live enforcement failed`, }; } finally { await this.rconService.disconnect(serverId); diff --git a/test/sanctions-warning.spec.ts b/test/sanctions-warning.spec.ts index 518a164d..8c48f2d1 100644 --- a/test/sanctions-warning.spec.ts +++ b/test/sanctions-warning.spec.ts @@ -149,11 +149,92 @@ describe("warning sanctions (SQL-driven)", () => { await sanction(warned, "warning"); await sanction(muted, "mute"); - expect(await service().getActiveServerSanctions("server-1")).toEqual([ - { steam_id: muted, is_banned: false, is_muted: true, is_gagged: false }, + expect( + await service().syncServerSanctions( + "00000000-0000-0000-0000-000000000000", + { steamIds: [warned, muted] }, + ), + ).toEqual([ + { steam_id: muted, type: "mute", reason: "reason", expires_at: null }, ]); }); + it("hands the server only live sanctions of the players it asked about", async () => { + const asked = await fx.player(); + const other = await fx.player(); + await sanction(asked, "ban"); + await sanction(asked, "gag", new Date(Date.now() - 60_000).toISOString()); + await sanction(other, "mute"); + + expect( + await service().syncServerSanctions( + "00000000-0000-0000-0000-000000000000", + { steamIds: [asked] }, + ), + ).toEqual([ + { steam_id: asked, type: "ban", reason: "reason", expires_at: null }, + ]); + }); + + it("records the plugin's heartbeat at most once a minute unless it changes", async () => { + await postgres.query( + "INSERT INTO server_regions (value, is_lan) VALUES ('PmTest', false) ON CONFLICT (value) DO NOTHING", + ); + const [{ id: serverId }] = await postgres.query>( + `INSERT INTO servers (host, label, rcon_password, port, enabled, region, type, is_dedicated) + VALUES ('127.0.0.1', 'pm', '\\x00'::bytea, 27916, true, 'PmTest', 'Casual', true) + RETURNING id`, + ); + const heartbeat = async () => { + const [row] = await postgres.query< + Array<{ + player_management_version: string | null; + player_management_runtime: string | null; + player_management_seen_at: Date | null; + }> + >( + `SELECT player_management_version, player_management_runtime, player_management_seen_at + FROM servers WHERE id = $1`, + [serverId], + ); + return row; + }; + const sync = (version: string) => + service().syncServerSanctions(serverId, { + pluginVersion: version, + pluginRuntime: "swiftlys2", + }); + + try { + await sync("0.0.1"); + const first = await heartbeat(); + expect(first.player_management_version).toBe("0.0.1"); + expect(first.player_management_runtime).toBe("swiftlys2"); + expect(first.player_management_seen_at).not.toBeNull(); + + await sync("0.0.1"); + expect((await heartbeat()).player_management_seen_at).toEqual( + first.player_management_seen_at, + ); + + await sync("0.0.2"); + expect((await heartbeat()).player_management_version).toBe("0.0.2"); + + await postgres.query( + `UPDATE servers + SET player_management_seen_at = now() - interval '90 seconds' + WHERE id = $1`, + [serverId], + ); + await sync("0.0.2"); + expect( + (await heartbeat()).player_management_seen_at!.getTime(), + ).toBeGreaterThan(Date.now() - 30_000); + } finally { + await postgres.query("DELETE FROM servers WHERE id = $1", [serverId]); + } + }); + it("removes one warning by id and leaves the rest of the record", async () => { const steamId = await fx.player(); const first = await sanction(steamId, "warning");