Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions hasura/metadata/databases/default/tables/public_servers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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;
42 changes: 42 additions & 0 deletions src/dedicated-servers/dedicated-servers.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});
});
38 changes: 28 additions & 10 deletions src/dedicated-servers/dedicated-servers.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
this.logger.log(`[${serverId}] assigning dedicated server`);

Expand Down Expand Up @@ -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",
Expand Down
63 changes: 63 additions & 0 deletions src/sanctions/sanctions.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
37 changes: 33 additions & 4 deletions src/sanctions/sanctions.controller.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
}),
};
}

Expand Down
2 changes: 1 addition & 1 deletion src/sanctions/sanctions.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
}
Loading
Loading