From c1f1ee7224c7ae270ca20738df32956b9a41e104 Mon Sep 17 00:00:00 2001 From: Mostafa Mahran Date: Wed, 11 Mar 2026 22:40:39 +0000 Subject: [PATCH 1/3] add dathost support --- __test__/dathost.test.js | 19 ++ __test__/matches.test.js | 39 +++ config/development.json.template | 6 + config/production.json.template | 6 + config/test.json.template | 6 + jest_config/jest.dathost.config.cjs | 13 + .../development/20250311120000-get5db.js | 53 ++++ .../production/20250311120000-get5db.js | 53 ++++ migrations/test/20250311120000-get5db.js | 53 ++++ package.json | 3 +- src/@types/express/index.d.ts | 1 + src/routes/matches/matches.ts | 76 +++++- src/routes/matches/matchserver.ts | 41 +-- src/routes/users.ts | 7 +- src/services/dathost.ts | 255 ++++++++++++++++++ src/services/seriesflowservices.ts | 6 +- src/types/User.ts | 1 + src/types/matches/MatchData.ts | 1 + src/types/servers/GameServerObject.ts | 4 +- src/types/users/UserObject.ts | 3 +- src/utility/auth.ts | 14 +- 21 files changed, 627 insertions(+), 33 deletions(-) create mode 100644 __test__/dathost.test.js create mode 100644 jest_config/jest.dathost.config.cjs create mode 100644 migrations/development/20250311120000-get5db.js create mode 100644 migrations/production/20250311120000-get5db.js create mode 100644 migrations/test/20250311120000-get5db.js create mode 100644 src/services/dathost.ts diff --git a/__test__/dathost.test.js b/__test__/dathost.test.js new file mode 100644 index 00000000..97bbd553 --- /dev/null +++ b/__test__/dathost.test.js @@ -0,0 +1,19 @@ +/** + * Unit tests for DatHost service (isDathostConfigured, releaseManagedServer with null). + * Does not require app or database. + */ +import { isDathostConfigured, releaseManagedServer } from "../src/services/dathost.js"; + +describe("DatHost service", () => { + it("isDathostConfigured returns false when dathost is not configured", () => { + expect(isDathostConfigured()).toBe(false); + }); + + it("releaseManagedServer(null) resolves without throwing", async () => { + await expect(releaseManagedServer(null)).resolves.toBeUndefined(); + }); + + it("releaseManagedServer(undefined) resolves without throwing", async () => { + await expect(releaseManagedServer(undefined)).resolves.toBeUndefined(); + }); +}); diff --git a/__test__/matches.test.js b/__test__/matches.test.js index 3b8ca034..4ca51d72 100644 --- a/__test__/matches.test.js +++ b/__test__/matches.test.js @@ -11,6 +11,45 @@ describe("Test the matches routes", () => { return request.get('/matches/') .expect(404); }); + it('Should reject use_dathost when server_id is also provided (400).', () => { + return request + .post("/matches/") + .set("Content-Type", "application/json") + .set("Accept", "application/json") + .send([{ + use_dathost: true, + server_id: 3, + team1_id: 4, + team2_id: 3, + max_maps: 1, + title: "Map {MAPNUMBER} of {MAXMAPS}", + veto_mappool: "de_dust2 de_mirage", + skip_veto: 0 + }]) + .expect(400) + .expect((result) => { + expect(result.body.message).toMatch(/use_dathost.*server_id/); + }); + }); + it('Should reject use_dathost when DatHost is not configured (503).', () => { + return request + .post("/matches/") + .set("Content-Type", "application/json") + .set("Accept", "application/json") + .send([{ + use_dathost: true, + team1_id: 4, + team2_id: 3, + max_maps: 1, + title: "Map {MAPNUMBER} of {MAXMAPS}", + veto_mappool: "de_dust2 de_mirage", + skip_veto: 0 + }]) + .expect(503) + .expect((result) => { + expect(result.body.message).toMatch(/DatHost|not configured/); + }); + }); it('Should create a single match that is ready to be played with teams.', () => { // Min required data. let newMatchData = [ diff --git a/config/development.json.template b/config/development.json.template index 01d3bbb2..cd90bc5e 100644 --- a/config/development.json.template +++ b/config/development.json.template @@ -29,6 +29,12 @@ }, "super_admins": { "steam_ids": "super_admins,go,here" + }, + "dathost": { + "email": "", + "password": "", + "steam_game_server_login_token": "", + "shutdown_delay_seconds": 0 } } \ No newline at end of file diff --git a/config/production.json.template b/config/production.json.template index 4cc0a41f..c9a8134f 100644 --- a/config/production.json.template +++ b/config/production.json.template @@ -29,5 +29,11 @@ }, "super_admins": { "steam_ids": "$SUPERADMINS" + }, + "dathost": { + "email": "$DATHOST_EMAIL", + "password": "$DATHOST_PASSWORD", + "steam_game_server_login_token": "$DATHOST_STEAM_TOKEN", + "shutdown_delay_seconds": 0 } } diff --git a/config/test.json.template b/config/test.json.template index 402b24b9..7d77dd32 100644 --- a/config/test.json.template +++ b/config/test.json.template @@ -29,6 +29,12 @@ }, "super_admins": { "steam_ids": "super_admins,go,here" + }, + "dathost": { + "email": "", + "password": "", + "steam_game_server_login_token": "", + "shutdown_delay_seconds": 0 } } \ No newline at end of file diff --git a/jest_config/jest.dathost.config.cjs b/jest_config/jest.dathost.config.cjs new file mode 100644 index 00000000..87b4b8fc --- /dev/null +++ b/jest_config/jest.dathost.config.cjs @@ -0,0 +1,13 @@ +process.env.NODE_ENV = "test"; +module.exports = { + preset: "ts-jest/presets/js-with-ts-esm", + resolver: "jest-ts-webcompat-resolver", + clearMocks: true, + roots: ["../__test__"], + testEnvironment: "node", + testMatch: [ + "**/dathost.test.js", + "**/@(dathost.)+(spec|test).[tj]s?(x)" + ], + verbose: false +}; diff --git a/migrations/development/20250311120000-get5db.js b/migrations/development/20250311120000-get5db.js new file mode 100644 index 00000000..ae6dc79f --- /dev/null +++ b/migrations/development/20250311120000-get5db.js @@ -0,0 +1,53 @@ +"use strict"; + +var dbm; +var type; +var seed; + +/** + * Add dathost_server_id and is_managed to game_server; + * add dathost_allowed to user for DatHost on-the-fly provisioning. + */ +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; +}; + +exports.up = function (db) { + return db + .addColumn("game_server", "dathost_server_id", { + type: "string", + length: 64, + notNull: false + }) + .then(function () { + return db.addColumn("game_server", "is_managed", { + type: "boolean", + notNull: true, + defaultValue: false + }); + }) + .then(function () { + return db.addColumn("user", "dathost_allowed", { + type: "boolean", + notNull: true, + defaultValue: false + }); + }); +}; + +exports.down = function (db) { + return db + .removeColumn("user", "dathost_allowed") + .then(function () { + return db.removeColumn("game_server", "is_managed"); + }) + .then(function () { + return db.removeColumn("game_server", "dathost_server_id"); + }); +}; + +exports._meta = { + version: 28 +}; diff --git a/migrations/production/20250311120000-get5db.js b/migrations/production/20250311120000-get5db.js new file mode 100644 index 00000000..ae6dc79f --- /dev/null +++ b/migrations/production/20250311120000-get5db.js @@ -0,0 +1,53 @@ +"use strict"; + +var dbm; +var type; +var seed; + +/** + * Add dathost_server_id and is_managed to game_server; + * add dathost_allowed to user for DatHost on-the-fly provisioning. + */ +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; +}; + +exports.up = function (db) { + return db + .addColumn("game_server", "dathost_server_id", { + type: "string", + length: 64, + notNull: false + }) + .then(function () { + return db.addColumn("game_server", "is_managed", { + type: "boolean", + notNull: true, + defaultValue: false + }); + }) + .then(function () { + return db.addColumn("user", "dathost_allowed", { + type: "boolean", + notNull: true, + defaultValue: false + }); + }); +}; + +exports.down = function (db) { + return db + .removeColumn("user", "dathost_allowed") + .then(function () { + return db.removeColumn("game_server", "is_managed"); + }) + .then(function () { + return db.removeColumn("game_server", "dathost_server_id"); + }); +}; + +exports._meta = { + version: 28 +}; diff --git a/migrations/test/20250311120000-get5db.js b/migrations/test/20250311120000-get5db.js new file mode 100644 index 00000000..ae6dc79f --- /dev/null +++ b/migrations/test/20250311120000-get5db.js @@ -0,0 +1,53 @@ +"use strict"; + +var dbm; +var type; +var seed; + +/** + * Add dathost_server_id and is_managed to game_server; + * add dathost_allowed to user for DatHost on-the-fly provisioning. + */ +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; +}; + +exports.up = function (db) { + return db + .addColumn("game_server", "dathost_server_id", { + type: "string", + length: 64, + notNull: false + }) + .then(function () { + return db.addColumn("game_server", "is_managed", { + type: "boolean", + notNull: true, + defaultValue: false + }); + }) + .then(function () { + return db.addColumn("user", "dathost_allowed", { + type: "boolean", + notNull: true, + defaultValue: false + }); + }); +}; + +exports.down = function (db) { + return db + .removeColumn("user", "dathost_allowed") + .then(function () { + return db.removeColumn("game_server", "is_managed"); + }) + .then(function () { + return db.removeColumn("game_server", "dathost_server_id"); + }); +}; + +exports._meta = { + version: 28 +}; diff --git a/package.json b/package.json index 877972be..f3e91fbc 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,8 @@ "test:teams": "NODE_OPTIONS=--experimental-vm-modules jest --testTimeout=10000 --detectOpenHandles --config ./jest_config/jest.teams.config.cjs", "test:user": "NODE_OPTIONS=--experimental-vm-modules jest --testTimeout=10000 --detectOpenHandles --config ./jest_config/jest.users.config.cjs", "test:vetoes": "NODE_OPTIONS=--experimental-vm-modules jest --testTimeout=10000 --detectOpenHandles --config ./jest_config/jest.vetoes.config.cjs", - "test:vetosides": "NODE_OPTIONS=--experimental-vm-modules jest --testTimeout=10000 --detectOpenHandles --config ./jest_config/jest.vetosides.config.cjs" + "test:vetosides": "NODE_OPTIONS=--experimental-vm-modules jest --testTimeout=10000 --detectOpenHandles --config ./jest_config/jest.vetosides.config.cjs", + "test:dathost": "NODE_OPTIONS=--experimental-vm-modules jest --testTimeout=5000 --config ./jest_config/jest.dathost.config.cjs" }, "dependencies": { "@node-steam/id": "^1.2.0", diff --git a/src/@types/express/index.d.ts b/src/@types/express/index.d.ts index 1ab726b8..20291ec6 100644 --- a/src/@types/express/index.d.ts +++ b/src/@types/express/index.d.ts @@ -14,6 +14,7 @@ declare global { medium_image: string large_image: string api_key: string + dathost_allowed?: boolean | number } } } diff --git a/src/routes/matches/matches.ts b/src/routes/matches/matches.ts index 7aa1ffec..afedfb84 100644 --- a/src/routes/matches/matches.ts +++ b/src/routes/matches/matches.ts @@ -8,7 +8,11 @@ import { Router } from "express"; const router = Router(); import { db } from "../../services/db.js"; - +import { + createAndStartServer, + isDathostConfigured, + releaseManagedServer +} from "../../services/dathost.js"; import { generate } from "randomstring"; import Utils from "../../utility/utils.js"; @@ -104,6 +108,9 @@ import { AccessMessage } from "../../types/mapstats/AccessMessage.js"; * ignore_server: * type: boolean * description: Boolean value representing whether to integrate a game server. + * use_dathost: + * type: boolean + * description: If true, provision a game server on DatHost on the fly (requires dathost_allowed, no server_id). * forfeit: * type: boolean * description: Whether the match was forfeited or not. @@ -1178,6 +1185,69 @@ router.get("/:match_id/config", async (req, res, next) => { */ router.post("/", Utils.ensureAuthenticated, async (req, res, next) => { try { + // DatHost on-the-fly provisioning: require server_id null, user permission, and config. + if (req.body[0].use_dathost) { + if (req.body[0].server_id != null) { + res.status(400).json({ + message: "use_dathost cannot be used together with server_id." + }); + return; + } + const userRows: RowDataPacket[] = await db.query( + "SELECT dathost_allowed FROM user WHERE id = ?", + [req.user!.id] + ); + if (!userRows[0]?.dathost_allowed) { + res.status(403).json({ + message: "You do not have permission to use DatHost provisioning." + }); + return; + } + if (!isDathostConfigured()) { + res.status(503).json({ + message: "DatHost is not configured. Contact the administrator." + }); + return; + } + const rconPassword = generate({ length: 16, capitalization: "uppercase" }); + const steamToken = config.get( + "dathost.steam_game_server_login_token" + ); + let dathostResult: { id: string; ip: string; port: number; rcon: string }; + try { + dathostResult = await createAndStartServer({ + name: `G5-${Date.now()}`, + rcon: rconPassword, + steamGameServerLoginToken: steamToken || "" + }); + } catch (e) { + console.error("DatHost createAndStartServer failed:", e); + res.status(502).json({ + message: + "Failed to provision a game server on DatHost. Please try again or use a different server." + }); + return; + } + const displayName = `DatHost-${dathostResult.id.slice(0, 8)}`; + const rconEncrypted = Utils.encrypt(dathostResult.rcon); + const insertServerSql = + "INSERT INTO game_server (user_id, ip_string, port, rcon_password, display_name, public_server, flag, gotv_port, dathost_server_id, is_managed) VALUES (?,?,?,?,?,?,?,?,?,?)"; + const insertServerResult = await db.query(insertServerSql, [ + req.user!.id, + dathostResult.ip, + dathostResult.port, + rconEncrypted, + displayName, + 0, + "", + null, + dathostResult.id, + 1 + ]); + const newServerId = (insertServerResult as any).insertId; + req.body[0].server_id = newServerId; + } + // Check if server available, if we are given a server. let serverSql: string = "SELECT in_use, user_id, public_server FROM game_server WHERE id = ?"; @@ -1307,9 +1377,7 @@ router.post("/", Utils.ensureAuthenticated, async (req, res, next) => { await db.query(sql, [insertMatch.insertId]); sql = "DELETE FROM `match` WHERE id = ?"; await db.query(sql, [insertMatch.insertId]); - - sql = "UPDATE game_server SET in_use = 0 WHERE id = ?"; - await db.query(sql, [req.body[0].server_id]); + await releaseManagedServer(req.body[0].server_id); throw "Please check server logs, as something was not set properly. You may cancel the match and server status is not updated."; } } diff --git a/src/routes/matches/matchserver.ts b/src/routes/matches/matchserver.ts index c735c69c..a05b56af 100644 --- a/src/routes/matches/matchserver.ts +++ b/src/routes/matches/matchserver.ts @@ -7,8 +7,8 @@ import { Router } from "express"; const router = Router(); -import {db} from "../../services/db.js"; - +import { db } from "../../services/db.js"; +import { releaseManagedServer } from "../../services/dathost.js"; import Utils from "../../utility/utils.js"; import GameServer from "../../utility/serverrcon.js"; @@ -114,7 +114,6 @@ router.get( "UPDATE map_stats SET ? WHERE match_id=? AND map_number=0"; } let matchSql: string = "UPDATE `match` SET ? WHERE id=?"; - let serverUpdateSql: string = "UPDATE game_server SET in_use=0 WHERE id=?"; if (!mapStat.length) { mapStatId = await db.query(mapStatSql, [newStatStmt]); } @@ -127,7 +126,6 @@ router.get( matchUpdateStmt, req.params.match_id, ]); - await db.query(serverUpdateSql, [matchRow[0].server_id]); if (matchRow[0].is_pug != null && matchRow[0].is_pug == 1) { await Utils.updatePugStats( req.params.match_id, @@ -138,12 +136,19 @@ router.get( teamIdWinner == 1 ? matchRow[0].team1_id : matchRow[0].team2_id ); } - let serverUpdate: GameServer = await getGameServer(req.params.match_id); - if (!serverUpdate.endGet5Match()) { - console.log( - "Error attempting to stop match on game server side. Will continue." - ); + if (matchRow[0].server_id != null) { + try { + const serverUpdate: GameServer = await getGameServer(req.params.match_id); + if (!serverUpdate.endGet5Match()) { + console.log( + "Error attempting to stop match on game server side. Will continue." + ); + } + } catch (e) { + console.log("Could not end match on server (e.g. managed server). Will continue.", e); + } } + await releaseManagedServer(matchRow[0].server_id); res.json({ message: "Match has been forfeitted successfully." }); return; } @@ -232,7 +237,6 @@ router.get( "UPDATE map_stats SET ? WHERE match_id=? AND map_number=0"; } let matchSql: string = "UPDATE `match` SET ? WHERE id=?"; - let serverUpdateSql: string = "UPDATE game_server SET in_use=0 WHERE id=?"; if (!mapStat.length) { mapStatId = await db.query(mapStatSql, [newStatStmt]); } @@ -245,7 +249,6 @@ router.get( matchUpdateStmt, req.params.match_id, ]); - await db.query(serverUpdateSql, [matchRow[0].server_id]); if (matchRow[0].is_pug != null && matchRow[0].is_pug == 1) { await Utils.updatePugStats( req.params.match_id, @@ -256,15 +259,19 @@ router.get( null ); } - // Let the server cancel the match first, or attempt to? if (matchRow[0].server_id != null) { - let serverUpdate: GameServer = await getGameServer(req.params.match_id); - if (!serverUpdate.endGet5Match()) { - console.log( - "Error attempting to stop match on game server side. Will continue." - ); + try { + const serverUpdate: GameServer = await getGameServer(req.params.match_id); + if (!serverUpdate.endGet5Match()) { + console.log( + "Error attempting to stop match on game server side. Will continue." + ); + } + } catch (e) { + console.log("Could not end match on server (e.g. managed server). Will continue.", e); } } + await releaseManagedServer(matchRow[0].server_id); res.json({ message: "Match has been cancelled successfully." }); return; } diff --git a/src/routes/users.ts b/src/routes/users.ts index 670f0b2e..0fbf0da6 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -323,6 +323,10 @@ router.put("/", Utils.ensureAuthenticated, async (req, res, next) => { let updateUser: UserObject; // Let admins force update passwords in the event of issues. if (req.user && Utils.adminCheck(req.user)) { + let dathostAllowed: boolean | undefined; + if (req.body[0].dathost_allowed !== undefined) { + dathostAllowed = Boolean(req.body[0].dathost_allowed); + } updateUser = { admin: isAdmin, super_admin: isSuperAdmin, @@ -332,7 +336,8 @@ router.put("/", Utils.ensureAuthenticated, async (req, res, next) => { large_image: largeImage, api_key: apiKey, password: password ? hashSync(password, 10) : null, - challonge_api_key: challongeApiKey + challonge_api_key: challongeApiKey, + ...(dathostAllowed !== undefined && { dathost_allowed: dathostAllowed }) }; } else if (req.user && req.user.steam_id == steamId || req.user!.id == userId) { if (req.body[0].force_reset) { diff --git a/src/services/dathost.ts b/src/services/dathost.ts new file mode 100644 index 00000000..4d7a82cf --- /dev/null +++ b/src/services/dathost.ts @@ -0,0 +1,255 @@ +/** + * DatHost REST API client for creating, starting, stopping, and deleting game servers. + * Used for on-the-fly server provisioning when use_dathost is true on match creation. + * @module services/dathost + */ + +import fetch from "node-fetch"; +import config from "config"; + +const BASE_URL = "https://dathost.net/api/0.1"; + +export interface DatHostServerCreateOptions { + name: string; + rcon: string; + steamGameServerLoginToken: string; + game?: string; +} + +export interface DatHostServerInfo { + id: string; + ip: string; + ports: { game: number }; + rcon: string; + status?: string; +} + +export interface CreateAndStartResult { + id: string; + ip: string; + port: number; + rcon: string; +} + +function getAuthHeader(): string { + const email = config.get("dathost.email"); + const password = config.get("dathost.password"); + const encoded = Buffer.from(`${email}:${password}`).toString("base64"); + return `Basic ${encoded}`; +} + +function isDathostConfigured(): boolean { + try { + const email = config.get("dathost.email"); + const password = config.get("dathost.password"); + return Boolean(email && password); + } catch { + return false; + } +} + +/** + * Create a game server on DatHost (does not start it). + */ +export async function createServer( + options: DatHostServerCreateOptions +): Promise { + const body = new URLSearchParams(); + body.append("name", options.name); + body.append("game", options.game ?? "cs2"); + body.append("csgo_settings.rcon", options.rcon); + body.append( + "csgo_settings.steam_game_server_login_token", + options.steamGameServerLoginToken + ); + + const res = await fetch(`${BASE_URL}/game-servers`, { + method: "POST", + headers: { + Authorization: getAuthHeader(), + "Content-Type": "application/x-www-form-urlencoded" + }, + body: body.toString() + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`DatHost create server failed: ${res.status} ${text}`); + } + + return (await res.json()) as DatHostServerInfo; +} + +/** + * Start a game server on DatHost. + */ +export async function startServer(dathostServerId: string): Promise { + const res = await fetch( + `${BASE_URL}/game-servers/${encodeURIComponent(dathostServerId)}/start`, + { + method: "POST", + headers: { + Authorization: getAuthHeader() + } + } + ); + + if (!res.ok) { + const text = await res.text(); + throw new Error( + `DatHost start server failed: ${res.status} ${text}` + ); + } +} + +/** + * Get current game server details (ip, ports, status). + */ +export async function getServer( + dathostServerId: string +): Promise { + const res = await fetch( + `${BASE_URL}/game-servers/${encodeURIComponent(dathostServerId)}`, + { + method: "GET", + headers: { + Authorization: getAuthHeader() + } + } + ); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`DatHost get server failed: ${res.status} ${text}`); + } + + return (await res.json()) as DatHostServerInfo; +} + +/** + * Stop a game server on DatHost. + */ +export async function stopServer(dathostServerId: string): Promise { + const res = await fetch( + `${BASE_URL}/game-servers/${encodeURIComponent(dathostServerId)}/stop`, + { + method: "POST", + headers: { + Authorization: getAuthHeader() + } + } + ); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`DatHost stop server failed: ${res.status} ${text}`); + } +} + +/** + * Delete a game server on DatHost. + */ +export async function deleteServer(dathostServerId: string): Promise { + const res = await fetch( + `${BASE_URL}/game-servers/${encodeURIComponent(dathostServerId)}`, + { + method: "DELETE", + headers: { + Authorization: getAuthHeader() + } + } + ); + + if (!res.ok) { + const text = await res.text(); + throw new Error( + `DatHost delete server failed: ${res.status} ${text}` + ); + } +} + +const POLL_INTERVAL_MS = 5000; +const POLL_TIMEOUT_MS = 300000; // 5 minutes + +/** + * Create a server, start it, and poll until it is ready (has ip and game port). + * Returns connection details for use in game_server row and RCON. + */ +export async function createAndStartServer( + options: DatHostServerCreateOptions +): Promise { + const server = await createServer(options); + const id = server.id; + const rcon = options.rcon; + + await startServer(id); + + const deadline = Date.now() + POLL_TIMEOUT_MS; + let info: DatHostServerInfo; + + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + info = await getServer(id); + if (info.ip && info.ports?.game) { + return { + id, + ip: info.ip, + port: info.ports.game, + rcon + }; + } + } + + throw new Error( + "DatHost server did not become ready in time (missing ip or game port)" + ); +} + +export { isDathostConfigured }; + +/** + * Release a game server after match end or cancel: set in_use=0 for normal servers, + * or for managed (DatHost) servers: stop and delete on DatHost, null match.server_id, delete game_server row. + * Safe to call with null serverId (no-op). + */ +export async function releaseManagedServer( + serverId: number | null | undefined +): Promise { + if (serverId == null) return; + + const { db } = await import("./db.js"); + const rows = await db.query( + "SELECT id, dathost_server_id, is_managed FROM game_server WHERE id = ?", + [serverId] + ); + const row = Array.isArray(rows) ? rows[0] : (rows as any)?.[0]; + if (!row) return; + + if (row.is_managed && row.dathost_server_id) { + const delaySeconds = + (config.has("dathost.shutdown_delay_seconds") && + config.get("dathost.shutdown_delay_seconds")) || + 0; + if (delaySeconds > 0) { + await new Promise((r) => setTimeout(r, delaySeconds * 1000)); + } + try { + await stopServer(row.dathost_server_id); + } catch (e) { + console.error("DatHost stopServer error:", e); + } + try { + await deleteServer(row.dathost_server_id); + } catch (e) { + console.error("DatHost deleteServer error:", e); + } + await db.query("UPDATE `match` SET server_id = NULL WHERE server_id = ?", [ + serverId + ]); + await db.query("DELETE FROM game_server WHERE id = ?", [serverId]); + } else { + await db.query("UPDATE game_server SET in_use = 0 WHERE id = ?", [ + serverId + ]); + } +} diff --git a/src/services/seriesflowservices.ts b/src/services/seriesflowservices.ts index ed417f9a..c1ae7739 100644 --- a/src/services/seriesflowservices.ts +++ b/src/services/seriesflowservices.ts @@ -1,4 +1,5 @@ import { db } from "./db.js"; +import { releaseManagedServer } from "./dathost.js"; import { Get5_OnSeriesResult } from "../types/series_flow/Get5_OnSeriesResult.js"; import { Get5_OnMapVetoed } from "../types/series_flow/veto/Get5_OnMapVetoed.js"; import { Get5_OnMapPicked } from "../types/series_flow/veto/Get5_OnMapPicked.js"; @@ -55,9 +56,8 @@ class SeriesFlowService { updateObject = await db.buildUpdateStatement(updateObject); let updateSql: string = "UPDATE `match` SET ? WHERE id = ?"; await db.query(updateSql, [updateObject, event.matchid]); - // Set server to not be in use. - updateSql = "UPDATE game_server SET in_use = 0 WHERE id = ?"; - await db.query(updateSql, [matchInfo[0].server_id]); + // Release server (in_use=0 for normal, or stop/delete DatHost managed server). + await releaseManagedServer(matchInfo[0].server_id); // Check if we are pugging. if (matchInfo[0].is_pug != null && matchInfo[0].is_pug == 1) { diff --git a/src/types/User.ts b/src/types/User.ts index 70178b20..5bcf36bd 100644 --- a/src/types/User.ts +++ b/src/types/User.ts @@ -8,4 +8,5 @@ export interface User { medium_image: string large_image: string api_key: string + dathost_allowed?: boolean | number } \ No newline at end of file diff --git a/src/types/matches/MatchData.ts b/src/types/matches/MatchData.ts index e092cc48..3f656f2e 100644 --- a/src/types/matches/MatchData.ts +++ b/src/types/matches/MatchData.ts @@ -32,4 +32,5 @@ export interface MatchData { wingman?: boolean, team1_series_score?: number, team2_series_score?: number, + use_dathost?: boolean, } \ No newline at end of file diff --git a/src/types/servers/GameServerObject.ts b/src/types/servers/GameServerObject.ts index 35b54e50..769e4a92 100644 --- a/src/types/servers/GameServerObject.ts +++ b/src/types/servers/GameServerObject.ts @@ -7,5 +7,7 @@ export interface GameServerObject { display_name?: string, public_server?: number, flag?: string, - gotv_port?: number + gotv_port?: number, + dathost_server_id?: string | null, + is_managed?: boolean } \ No newline at end of file diff --git a/src/types/users/UserObject.ts b/src/types/users/UserObject.ts index dd73cdf5..7f21fb71 100644 --- a/src/types/users/UserObject.ts +++ b/src/types/users/UserObject.ts @@ -1,4 +1,4 @@ -export interface UserObject { +export interface UserObject { steam_id?: string, name?: string, admin?: number, @@ -9,4 +9,5 @@ export interface UserObject { api_key?: string | undefined | null, challonge_api_key?: string | undefined | null, password?: string | null | undefined, + dathost_allowed?: boolean, } \ No newline at end of file diff --git a/src/utility/auth.ts b/src/utility/auth.ts index 98d16b05..3a7ede91 100644 --- a/src/utility/auth.ts +++ b/src/utility/auth.ts @@ -13,6 +13,7 @@ import {db} from "../services/db.js"; import { generate } from "randomstring"; import Utils from "./utils.js"; import User from "steamapi/dist/src/structures/User.js"; +import type { User as SessionUser } from "../types/User.js"; passport.serializeUser((user, done) => { done(null, user); @@ -129,7 +130,8 @@ async function returnStrategy(identifier: any, profile: any, done: any) { medium_image: profile.photos[1].value, large_image: profile.photos[2].value, api_key: curUser[0].id + ":" + Utils.decrypt(curUser[0].api_key), - }); + dathost_allowed: curUser[0].dathost_allowed, + } as SessionUser); } catch (err) { console.log(profile.toString()); console.log( @@ -164,8 +166,9 @@ passport.use('local-login', new LocalStrategy(async (username, password, done) = small_image: curUser[0].small_image, medium_image: curUser[0].medium_image, large_image: curUser[0].large_image, - api_key: curUser[0].id + ":" + Utils.decrypt(curUser[0].api_key) - }); + api_key: curUser[0].id + ":" + Utils.decrypt(curUser[0].api_key), + dathost_allowed: curUser[0].dathost_allowed, + } as SessionUser); } else { return done(null, false, {message: "Invalid username or password."}); } @@ -243,8 +246,9 @@ passport.use('local-register', small_image: curUser[0].small_image, medium_image: curUser[0].medium_image, large_image: curUser[0].large_image, - api_key: curUser[0].id + ":" + Utils.decrypt(curUser[0].api_key) - }); + api_key: curUser[0].id + ":" + Utils.decrypt(curUser[0].api_key), + dathost_allowed: curUser[0].dathost_allowed, + } as SessionUser); } } catch (e) { console.error(e); From 836a65f484fe0c427b7571e303acf39ef6366340 Mon Sep 17 00:00:00 2001 From: Mostafa Mahran Date: Thu, 12 Mar 2026 20:03:24 +0000 Subject: [PATCH 2/3] support plugin installation --- src/services/dathost.ts | 148 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 140 insertions(+), 8 deletions(-) diff --git a/src/services/dathost.ts b/src/services/dathost.ts index 4d7a82cf..ba4e9699 100644 --- a/src/services/dathost.ts +++ b/src/services/dathost.ts @@ -4,7 +4,7 @@ * @module services/dathost */ -import fetch from "node-fetch"; +import fetch, { FormData, Blob } from "node-fetch"; import config from "config"; const BASE_URL = "https://dathost.net/api/0.1"; @@ -22,6 +22,8 @@ export interface DatHostServerInfo { ports: { game: number }; rcon: string; status?: string; + booting?: boolean; + on?: boolean; } export interface CreateAndStartResult { @@ -55,13 +57,24 @@ export async function createServer( options: DatHostServerCreateOptions ): Promise { const body = new URLSearchParams(); + const game = options.game ?? "cs2"; body.append("name", options.name); - body.append("game", options.game ?? "cs2"); - body.append("csgo_settings.rcon", options.rcon); - body.append( - "csgo_settings.steam_game_server_login_token", - options.steamGameServerLoginToken - ); + body.append("game", game); + + if (game === "cs2") { + body.append("cs2_settings.rcon", options.rcon); + body.append( + "cs2_settings.steam_game_server_login_token", + options.steamGameServerLoginToken + ); + body.append("cs2_settings.enable_metamod", "true"); + } else { + body.append("csgo_settings.rcon", options.rcon); + body.append( + "csgo_settings.steam_game_server_login_token", + options.steamGameServerLoginToken + ); + } const res = await fetch(`${BASE_URL}/game-servers`, { method: "POST", @@ -168,6 +181,124 @@ export async function deleteServer(dathostServerId: string): Promise { } } +async function downloadToBuffer(url: string): Promise { + const res = await fetch(url, { redirect: "follow" }); + if (!res.ok) { + throw new Error(`Download failed: ${res.status} ${url}`); + } + return res.arrayBuffer(); +} + +async function uploadFileToDathost( + serverId: string, + remotePath: string, + data: ArrayBuffer, + fileName: string +): Promise { + const fd = new FormData(); + fd.append("file", new Blob([data], { type: "application/zip" }), fileName); + + const res = await fetch( + `${BASE_URL}/game-servers/${encodeURIComponent(serverId)}/files/${encodeURIComponent(remotePath)}`, + { + method: "POST", + headers: { Authorization: getAuthHeader() }, + body: fd + } + ); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`DatHost upload failed (${remotePath}): ${res.status} ${text}`); + } +} + +async function unzipOnDathost( + serverId: string, + zipPath: string, + destination: string +): Promise { + const body = new URLSearchParams(); + body.append("destination", destination); + + const res = await fetch( + `${BASE_URL}/game-servers/${encodeURIComponent(serverId)}/unzip/${encodeURIComponent(zipPath)}`, + { + method: "POST", + headers: { + Authorization: getAuthHeader(), + "Content-Type": "application/x-www-form-urlencoded" + }, + body: body.toString() + } + ); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`DatHost unzip failed (${zipPath}): ${res.status} ${text}`); + } +} + +const CSS_GITHUB_LATEST = + "https://api.github.com/repos/roflmuffin/CounterStrikeSharp/releases/latest"; +const MATCHZY_GITHUB_LATEST = + "https://api.github.com/repos/shobhit-pathak/MatchZy/releases/latest"; + +interface GitHubAsset { + name: string; + browser_download_url: string; +} + +async function installPlugins(serverId: string): Promise { + const releaseRes = await fetch(CSS_GITHUB_LATEST, { + headers: { "User-Agent": "G5API" } + }); + if (!releaseRes.ok) { + throw new Error( + `Failed to fetch CSS latest release: ${releaseRes.status}` + ); + } + const release = (await releaseRes.json()) as { assets: GitHubAsset[] }; + const cssAsset = release.assets.find((a) => + a.name.includes("with-runtime-linux") + ); + if (!cssAsset) { + throw new Error("CounterStrikeSharp with-runtime-linux asset not found in latest release"); + } + + console.log(`Downloading CounterStrikeSharp: ${cssAsset.name}`); + const cssBuf = await downloadToBuffer(cssAsset.browser_download_url); + console.log(`Uploading CounterStrikeSharp (${cssBuf.byteLength} bytes) to DatHost...`); + await uploadFileToDathost(serverId, "counterstrikesharp.zip", cssBuf, cssAsset.name); + console.log("Extracting CounterStrikeSharp..."); + await unzipOnDathost(serverId, "counterstrikesharp.zip", "/"); + + const matchzyReleaseRes = await fetch(MATCHZY_GITHUB_LATEST, { + headers: { "User-Agent": "G5API" } + }); + if (!matchzyReleaseRes.ok) { + throw new Error( + `Failed to fetch MatchZy latest release: ${matchzyReleaseRes.status}` + ); + } + const matchzyRelease = (await matchzyReleaseRes.json()) as { assets: GitHubAsset[] }; + const matchzyAsset = matchzyRelease.assets.find( + (a) => a.name.endsWith(".zip") && !a.name.includes("with-cssharp") + ); + if (!matchzyAsset) { + throw new Error("MatchZy plugin-only zip asset not found in latest release"); + } + + console.log(`Downloading MatchZy: ${matchzyAsset.name}`); + const matchzyBuf = await downloadToBuffer(matchzyAsset.browser_download_url); + console.log(`Uploading MatchZy (${matchzyBuf.byteLength} bytes) to DatHost...`); + await uploadFileToDathost(serverId, "matchzy.zip", matchzyBuf, matchzyAsset.name); + console.log("Extracting MatchZy..."); + await unzipOnDathost(serverId, "matchzy.zip", "/"); + + console.log("Plugin installation complete."); +} + const POLL_INTERVAL_MS = 5000; const POLL_TIMEOUT_MS = 300000; // 5 minutes @@ -182,6 +313,7 @@ export async function createAndStartServer( const id = server.id; const rcon = options.rcon; + await installPlugins(id); await startServer(id); const deadline = Date.now() + POLL_TIMEOUT_MS; @@ -190,7 +322,7 @@ export async function createAndStartServer( while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); info = await getServer(id); - if (info.ip && info.ports?.game) { + if (info.ip && info.ports?.game && info.on && !info.booting) { return { id, ip: info.ip, From 625929f73fdde27320eb2042dfb717da364cf3f4 Mon Sep 17 00:00:00 2001 From: Mostafa Mahran Date: Mon, 23 Mar 2026 16:18:50 +0000 Subject: [PATCH 3/3] replace server rcon with a basic implementation, add support for dathost queues, encrypted creds stored in db, support for both cs:go and cs2 in queues --- __test__/dathost.test.js | 4 +- __test__/queue.test.js | 41 +- app.ts | 2 + config/development.json.template | 9 +- config/production.json.template | 9 +- config/test.json.template | 9 +- .../development/20260317000000-get5db.js | 45 ++ .../production/20260317000000-get5db.js | 45 ++ migrations/test/20260317000000-get5db.js | 45 ++ src/@types/express/index.d.ts | 1 - src/routes/dathost-config.ts | 143 ++++ src/routes/matches/matches.ts | 26 +- src/routes/matches/matchserver.ts | 4 + src/routes/queue.ts | 51 +- src/routes/users.ts | 7 +- src/services/dathost.ts | 636 +++++++++++++----- src/services/queue.ts | 200 +++++- src/types/User.ts | 1 - src/types/dathost/CreateAndStartResult.ts | 6 + src/types/dathost/DatHostConfig.ts | 7 + .../dathost/DatHostServerCreateOptions.ts | 6 + src/types/dathost/DatHostServerInfo.ts | 9 + src/types/queues/QueueDescriptor.ts | 1 + src/types/users/UserObject.ts | 1 - src/utility/auth.ts | 3 - src/utility/serverrcon.ts | 254 ++++++- 26 files changed, 1294 insertions(+), 271 deletions(-) create mode 100644 migrations/development/20260317000000-get5db.js create mode 100644 migrations/production/20260317000000-get5db.js create mode 100644 migrations/test/20260317000000-get5db.js create mode 100644 src/routes/dathost-config.ts create mode 100644 src/types/dathost/CreateAndStartResult.ts create mode 100644 src/types/dathost/DatHostConfig.ts create mode 100644 src/types/dathost/DatHostServerCreateOptions.ts create mode 100644 src/types/dathost/DatHostServerInfo.ts diff --git a/__test__/dathost.test.js b/__test__/dathost.test.js index 97bbd553..b8078369 100644 --- a/__test__/dathost.test.js +++ b/__test__/dathost.test.js @@ -5,8 +5,8 @@ import { isDathostConfigured, releaseManagedServer } from "../src/services/dathost.js"; describe("DatHost service", () => { - it("isDathostConfigured returns false when dathost is not configured", () => { - expect(isDathostConfigured()).toBe(false); + it("isDathostConfigured returns false when user dathost is not configured", async () => { + await expect(isDathostConfigured(1)).resolves.toBe(false); }); it("releaseManagedServer(null) resolves without throwing", async () => { diff --git a/__test__/queue.test.js b/__test__/queue.test.js index 29178e6c..d996dc39 100644 --- a/__test__/queue.test.js +++ b/__test__/queue.test.js @@ -1,7 +1,12 @@ import { agent } from 'supertest'; +import config from 'config'; +import { jest } from '@jest/globals'; import app from '../app.js'; import { db } from '../src/services/db.js'; -import { QueueService } from '../src/services/queue.js'; +import { + QueueService, + QueueOwnerDatHostConfigMissingError +} from '../src/services/queue.js'; const request = agent(app); describe('Queue routes', () => { @@ -143,4 +148,38 @@ describe('Queue routes', () => { Math.random = realRandom; }); }); + + it('should throw a clear error when queue owner lacks DatHost config', async () => { + const originalHas = config.has.bind(config); + const originalGet = config.get.bind(config); + let descriptor; + try { + jest.spyOn(config, 'has').mockImplementation((key) => { + if (key === 'server.serverProvider') return true; + return originalHas(key); + }); + jest.spyOn(config, 'get').mockImplementation((key) => { + if (key === 'server.serverProvider') return 'dathost'; + return originalGet(key); + }); + + descriptor = await QueueService.createQueue( + '76561198025644195', + 'OwnerNoDatHostConfig', + 10, + false, + 'cs2', + 120 + ); + + await expect( + QueueService.createMatchFromQueue(descriptor.name, [1, 2]) + ).rejects.toThrow(QueueOwnerDatHostConfigMissingError); + } finally { + if (descriptor?.name) { + await QueueService.deleteQueue(descriptor.name, '76561198025644195'); + } + jest.restoreAllMocks(); + } + }); }); diff --git a/app.ts b/app.ts index 1c398e3b..2b48b6d7 100644 --- a/app.ts +++ b/app.ts @@ -32,6 +32,7 @@ import usersRouter from "./src/routes/users.js"; import vetoesRouter from "./src/routes/vetoes.js"; import vetosidesRouter from "./src/routes/vetosides.js"; import passport from "./src/utility/auth.js"; +import dathostConfigRouter from "./src/routes/dathost-config.js"; import {router as v2Router} from "./src/routes/v2/api.js"; import {router as v2DemoRouter} from "./src/routes/v2/demoapi.js"; import { router as v2BackupRouter } from "./src/routes/v2/backupapi.js"; @@ -149,6 +150,7 @@ app.use("/seasons", seasonsRouter); app.use("/match", legacyAPICalls); app.use("/leaderboard", leaderboardRouter); app.use("/maps", mapListRouter); +app.use("/dathost-config", dathostConfigRouter); app.use("/v2", v2Router); app.use("/v2/demo", v2DemoRouter); app.use("/v2/backup", v2BackupRouter); diff --git a/config/development.json.template b/config/development.json.template index 913a5d60..951deda0 100644 --- a/config/development.json.template +++ b/config/development.json.template @@ -13,7 +13,8 @@ "redisUrl": "redis://:super_secure@localhost:6379", "redisTTL": 86400, "queueTTL": 3600, - "serverPingTimeoutMs": 5000 + "serverPingTimeoutMs": 5000, + "serverProvider": "local" }, "development": { "driver": "mysql", @@ -32,12 +33,6 @@ "super_admins": { "steam_ids": "super_admins,go,here" }, - "dathost": { - "email": "", - "password": "", - "steam_game_server_login_token": "", - "shutdown_delay_seconds": 0 - } "defaultMaps": [ { "map_name": "de_inferno", "map_display_name": "Inferno" }, { "map_name": "de_ancient", "map_display_name": "Ancient" }, diff --git a/config/production.json.template b/config/production.json.template index 0813fade..860da83f 100644 --- a/config/production.json.template +++ b/config/production.json.template @@ -13,7 +13,8 @@ "redisUrl": "$REDISURL", "redisTTL": $REDISTTL, "queueTTL": $QUEUETTL, - "serverPingTimeoutMs": $SERVERPINGTO + "serverPingTimeoutMs": $SERVERPINGTO, + "serverProvider": "$SERVERPROVIDER" }, "production": { "driver": "mysql", @@ -32,12 +33,6 @@ "super_admins": { "steam_ids": "$SUPERADMINS" }, - "dathost": { - "email": "$DATHOST_EMAIL", - "password": "$DATHOST_PASSWORD", - "steam_game_server_login_token": "$DATHOST_STEAM_TOKEN", - "shutdown_delay_seconds": 0 - } "defaultMaps": [ { "map_name": "de_inferno", "map_display_name": "Inferno" }, { "map_name": "de_ancient", "map_display_name": "Ancient" }, diff --git a/config/test.json.template b/config/test.json.template index 2be99bb2..ead8329b 100644 --- a/config/test.json.template +++ b/config/test.json.template @@ -13,7 +13,8 @@ "redisUrl": "redis://:super_secure@localhost:6379", "redisTTL": 86400, "queueTTL": 3600, - "serverPingTimeoutMs": 5000 + "serverPingTimeoutMs": 5000, + "serverProvider": "local" }, "test": { "driver": "mysql", @@ -32,12 +33,6 @@ "super_admins": { "steam_ids": "super_admins,go,here" }, - "dathost": { - "email": "", - "password": "", - "steam_game_server_login_token": "", - "shutdown_delay_seconds": 0 - } "defaultMaps": [ { "map_name": "de_inferno", "map_display_name": "Inferno" }, { "map_name": "de_ancient", "map_display_name": "Ancient" }, diff --git a/migrations/development/20260317000000-get5db.js b/migrations/development/20260317000000-get5db.js new file mode 100644 index 00000000..a231ad4c --- /dev/null +++ b/migrations/development/20260317000000-get5db.js @@ -0,0 +1,45 @@ +"use strict"; + +var dbm; +var type; +var seed; + +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; +}; + +exports.up = function (db) { + return db + .runSql( + "CREATE TABLE IF NOT EXISTS dathost_config (" + + "id INT NOT NULL AUTO_INCREMENT, " + + "email VARCHAR(512) NOT NULL, " + + "password VARCHAR(512) NOT NULL, " + + "steam_game_server_login_token VARCHAR(512) NOT NULL DEFAULT '', " + + "shutdown_delay_seconds INT NOT NULL DEFAULT 0, " + + "preferred_location VARCHAR(64) NOT NULL DEFAULT '', " + + "PRIMARY KEY (id)" + + ")" + ) + .then(function () { + return db.runSql( + "ALTER TABLE dathost_config " + + "ADD COLUMN user_id INT NULL, " + + "ADD UNIQUE INDEX uq_dathost_config_user_id (user_id)" + ); + }); +}; + +exports.down = function (db) { + return db.runSql( + "ALTER TABLE dathost_config " + + "DROP INDEX uq_dathost_config_user_id, " + + "DROP COLUMN user_id" + ); +}; + +exports._meta = { + version: 29 +}; diff --git a/migrations/production/20260317000000-get5db.js b/migrations/production/20260317000000-get5db.js new file mode 100644 index 00000000..a231ad4c --- /dev/null +++ b/migrations/production/20260317000000-get5db.js @@ -0,0 +1,45 @@ +"use strict"; + +var dbm; +var type; +var seed; + +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; +}; + +exports.up = function (db) { + return db + .runSql( + "CREATE TABLE IF NOT EXISTS dathost_config (" + + "id INT NOT NULL AUTO_INCREMENT, " + + "email VARCHAR(512) NOT NULL, " + + "password VARCHAR(512) NOT NULL, " + + "steam_game_server_login_token VARCHAR(512) NOT NULL DEFAULT '', " + + "shutdown_delay_seconds INT NOT NULL DEFAULT 0, " + + "preferred_location VARCHAR(64) NOT NULL DEFAULT '', " + + "PRIMARY KEY (id)" + + ")" + ) + .then(function () { + return db.runSql( + "ALTER TABLE dathost_config " + + "ADD COLUMN user_id INT NULL, " + + "ADD UNIQUE INDEX uq_dathost_config_user_id (user_id)" + ); + }); +}; + +exports.down = function (db) { + return db.runSql( + "ALTER TABLE dathost_config " + + "DROP INDEX uq_dathost_config_user_id, " + + "DROP COLUMN user_id" + ); +}; + +exports._meta = { + version: 29 +}; diff --git a/migrations/test/20260317000000-get5db.js b/migrations/test/20260317000000-get5db.js new file mode 100644 index 00000000..a231ad4c --- /dev/null +++ b/migrations/test/20260317000000-get5db.js @@ -0,0 +1,45 @@ +"use strict"; + +var dbm; +var type; +var seed; + +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; +}; + +exports.up = function (db) { + return db + .runSql( + "CREATE TABLE IF NOT EXISTS dathost_config (" + + "id INT NOT NULL AUTO_INCREMENT, " + + "email VARCHAR(512) NOT NULL, " + + "password VARCHAR(512) NOT NULL, " + + "steam_game_server_login_token VARCHAR(512) NOT NULL DEFAULT '', " + + "shutdown_delay_seconds INT NOT NULL DEFAULT 0, " + + "preferred_location VARCHAR(64) NOT NULL DEFAULT '', " + + "PRIMARY KEY (id)" + + ")" + ) + .then(function () { + return db.runSql( + "ALTER TABLE dathost_config " + + "ADD COLUMN user_id INT NULL, " + + "ADD UNIQUE INDEX uq_dathost_config_user_id (user_id)" + ); + }); +}; + +exports.down = function (db) { + return db.runSql( + "ALTER TABLE dathost_config " + + "DROP INDEX uq_dathost_config_user_id, " + + "DROP COLUMN user_id" + ); +}; + +exports._meta = { + version: 29 +}; diff --git a/src/@types/express/index.d.ts b/src/@types/express/index.d.ts index 20291ec6..1ab726b8 100644 --- a/src/@types/express/index.d.ts +++ b/src/@types/express/index.d.ts @@ -14,7 +14,6 @@ declare global { medium_image: string large_image: string api_key: string - dathost_allowed?: boolean | number } } } diff --git a/src/routes/dathost-config.ts b/src/routes/dathost-config.ts new file mode 100644 index 00000000..afa2c30e --- /dev/null +++ b/src/routes/dathost-config.ts @@ -0,0 +1,143 @@ +/** + * @swagger + * resourcePath: /dathost-config + * description: API for managing DatHost credentials stored encrypted in the database. + */ + +import { Router } from "express"; +import Utils from "../utility/utils.js"; +import { + getDathostConfig, + isValidDatHostLocationId, + setDathostConfig +} from "../services/dathost.js"; + +const router = Router(); + +/** + * @swagger + * + * /dathost-config: + * get: + * description: Get current user DatHost configuration. Password and token are masked. + * produces: + * - application/json + * tags: + * - dathost-config + * responses: + * 200: + * description: Current DatHost config. + * 401: + * $ref: '#/components/responses/Error' + */ +router.get("/", Utils.ensureAuthenticated, async (req, res, next) => { + try { + const cfg = await getDathostConfig(req.user!.id); + if (!cfg) { + res.json({ + email: "", + password: "", + steam_game_server_login_token: "", + shutdown_delay_seconds: 0, + preferred_location: "", + configured: false + }); + return; + } + + res.json({ + email: cfg.email, + password: cfg.password ? "********" : "", + steam_game_server_login_token: cfg.steamGameServerLoginToken + ? "********" + : "", + shutdown_delay_seconds: cfg.shutdownDelaySeconds, + preferred_location: cfg.preferredLocation, + configured: Boolean(cfg.email && cfg.password && cfg.preferredLocation) + }); + } catch (err) { + next(err); + } +}); + +/** + * @swagger + * + * /dathost-config: + * put: + * description: Set DatHost configuration for the authenticated user. Credentials are encrypted before storage. + * produces: + * - application/json + * tags: + * - dathost-config + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * email: + * type: string + * password: + * type: string + * steam_game_server_login_token: + * type: string + * shutdown_delay_seconds: + * type: integer + * preferred_location: + * type: string + * responses: + * 200: + * description: Config saved. + * 401: + * $ref: '#/components/responses/Error' + * 400: + * $ref: '#/components/responses/Error' + */ +router.put("/", Utils.ensureAuthenticated, async (req, res, next) => { + try { + const { + email, + password, + steam_game_server_login_token, + shutdown_delay_seconds, + preferred_location + } = req.body; + + if ( + typeof email !== "string" || + typeof password !== "string" || + typeof preferred_location !== "string" || + !preferred_location + ) { + res + .status(400) + .json({ + message: + "email, password, and preferred_location are required strings." + }); + return; + } + + if (!isValidDatHostLocationId(preferred_location)) { + res.status(400).json({ message: "Invalid preferred_location value." }); + return; + } + + await setDathostConfig( + req.user!.id, + email, + password, + steam_game_server_login_token ?? "", + typeof shutdown_delay_seconds === "number" ? shutdown_delay_seconds : 0, + preferred_location + ); + + res.json({ message: "DatHost configuration saved." }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/src/routes/matches/matches.ts b/src/routes/matches/matches.ts index afedfb84..ddf662c4 100644 --- a/src/routes/matches/matches.ts +++ b/src/routes/matches/matches.ts @@ -11,6 +11,7 @@ import { db } from "../../services/db.js"; import { createAndStartServer, isDathostConfigured, + getDathostConfig, releaseManagedServer } from "../../services/dathost.js"; import { generate } from "randomstring"; @@ -110,7 +111,7 @@ import { AccessMessage } from "../../types/mapstats/AccessMessage.js"; * description: Boolean value representing whether to integrate a game server. * use_dathost: * type: boolean - * description: If true, provision a game server on DatHost on the fly (requires dathost_allowed, no server_id). + * description: If true, provision a game server on DatHost on the fly (requires DatHost integration configured, no server_id). * forfeit: * type: boolean * description: Whether the match was forfeited or not. @@ -1185,7 +1186,7 @@ router.get("/:match_id/config", async (req, res, next) => { */ router.post("/", Utils.ensureAuthenticated, async (req, res, next) => { try { - // DatHost on-the-fly provisioning: require server_id null, user permission, and config. + // DatHost on-the-fly provisioning: require server_id null and DatHost config. if (req.body[0].use_dathost) { if (req.body[0].server_id != null) { res.status(400).json({ @@ -1193,29 +1194,18 @@ router.post("/", Utils.ensureAuthenticated, async (req, res, next) => { }); return; } - const userRows: RowDataPacket[] = await db.query( - "SELECT dathost_allowed FROM user WHERE id = ?", - [req.user!.id] - ); - if (!userRows[0]?.dathost_allowed) { - res.status(403).json({ - message: "You do not have permission to use DatHost provisioning." - }); - return; - } - if (!isDathostConfigured()) { + if (!(await isDathostConfigured(req.user!.id))) { res.status(503).json({ - message: "DatHost is not configured. Contact the administrator." + message: "DatHost is not configured on this instance of G5API." }); return; } const rconPassword = generate({ length: 16, capitalization: "uppercase" }); - const steamToken = config.get( - "dathost.steam_game_server_login_token" - ); + const dathostCfg = await getDathostConfig(req.user!.id); + const steamToken = dathostCfg?.steamGameServerLoginToken ?? ""; let dathostResult: { id: string; ip: string; port: number; rcon: string }; try { - dathostResult = await createAndStartServer({ + dathostResult = await createAndStartServer(req.user!.id, { name: `G5-${Date.now()}`, rcon: rconPassword, steamGameServerLoginToken: steamToken || "" diff --git a/src/routes/matches/matchserver.ts b/src/routes/matches/matchserver.ts index 3ce87998..38126784 100644 --- a/src/routes/matches/matchserver.ts +++ b/src/routes/matches/matchserver.ts @@ -12,6 +12,7 @@ import { releaseManagedServer } from "../../services/dathost.js"; import Utils from "../../utility/utils.js"; import GameServer from "../../utility/serverrcon.js"; +import GlobalEmitter from "../../utility/emitter.js"; import config from "config"; @@ -149,6 +150,7 @@ router.get( } } await releaseManagedServer(matchRow[0].server_id); + GlobalEmitter.emit("matchUpdate"); res.json({ message: "Match has been forfeitted successfully." }); return; } @@ -272,6 +274,7 @@ router.get( } } await releaseManagedServer(matchRow[0].server_id); + GlobalEmitter.emit("matchUpdate"); res.json({ message: "Match has been cancelled successfully." }); return; } @@ -385,6 +388,7 @@ router.get( req.params.match_id, ]); } + GlobalEmitter.emit("matchUpdate"); res.json({ message: "Match has been restarted successfully." }); return; } diff --git a/src/routes/queue.ts b/src/routes/queue.ts index 59d9901e..6e71961c 100644 --- a/src/routes/queue.ts +++ b/src/routes/queue.ts @@ -6,10 +6,15 @@ import config from "config"; import { Router } from 'express'; import Utils from "../utility/utils.js"; -import { QueueService } from "../services/queue.js"; +import { + QueueService, + QueueOwnerDatHostConfigMissingError +} from "../services/queue.js"; import GlobalEmitter from "../utility/emitter.js"; const router = Router(); +type QueueGame = "cs2" | "csgo"; +const DEFAULT_QUEUE_GAME: QueueGame = "cs2"; const getRequesterRole = (req: any): string => { let role: string = 'user'; @@ -80,6 +85,12 @@ const buildQueueState = async (slug: string): Promise => { * nullable: true * description: Optional flag for visibility * example: false + * game: + * type: string + * nullable: true + * enum: [cs2, csgo] + * description: Selected game for this queue. + * example: cs2 * responses: * NoSeasonData: * description: No season data was provided. @@ -337,6 +348,11 @@ router.get('/:slug/players', Utils.ensureAuthenticated, async (req, res) => { * description: Whether the queue is private or will be listed publically. * example: false * required: false + * game: + * type: string + * description: Game type for the queue. + * enum: [cs2, csgo] + * example: cs2 * responses: * 200: * description: New season inserted successsfully. @@ -348,11 +364,31 @@ router.get('/:slug/players', Utils.ensureAuthenticated, async (req, res) => { * $ref: '#/components/responses/Error' */ router.post('/', Utils.ensureAuthenticated, async (req, res) => { - const maxPlayers: number = req.body[0].maxPlayers; - const isPrivate: boolean = req.body[0].private ? true : false; + const payload = req.body?.[0] ?? {}; + const maxPlayers: number = Number(payload.maxPlayers); + const isPrivate: boolean = payload.private ? true : false; + const requestedGame = payload.game; + + if (!Number.isFinite(maxPlayers) || maxPlayers <= 0) { + return res.status(400).json({ error: "Invalid maxPlayers value." }); + } + + let game: QueueGame = DEFAULT_QUEUE_GAME; + if (requestedGame != null) { + if (requestedGame !== "cs2" && requestedGame !== "csgo") { + return res.status(400).json({ error: 'Invalid game. Must be "cs2" or "csgo".' }); + } + game = requestedGame; + } try { - const descriptor = await QueueService.createQueue(req.user?.steam_id!, req.user?.name!, maxPlayers, isPrivate); + const descriptor = await QueueService.createQueue( + req.user?.steam_id!, + req.user?.name!, + maxPlayers, + isPrivate, + game + ); res.json({ message: "Queue created successfully!", url: `${config.get("server.apiURL")}/queue/${descriptor.name}` }); } catch (error) { console.error('Error creating queue:', error); @@ -454,6 +490,13 @@ router.put('/:slug', Utils.ensureAuthenticated, async (req, res) => { } } catch (err) { console.error('Error creating teams or match from queue:', err); + if (err instanceof QueueOwnerDatHostConfigMissingError) { + res.status(412).json({ + error: + "Queue owner has not configured DatHost credentials. Ask the queue owner to configure DatHost in Settings." + }); + return; + } res.status(500).json({ error: 'Failed to create teams or match from queue.' }); return; } finally { diff --git a/src/routes/users.ts b/src/routes/users.ts index 0fbf0da6..670f0b2e 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -323,10 +323,6 @@ router.put("/", Utils.ensureAuthenticated, async (req, res, next) => { let updateUser: UserObject; // Let admins force update passwords in the event of issues. if (req.user && Utils.adminCheck(req.user)) { - let dathostAllowed: boolean | undefined; - if (req.body[0].dathost_allowed !== undefined) { - dathostAllowed = Boolean(req.body[0].dathost_allowed); - } updateUser = { admin: isAdmin, super_admin: isSuperAdmin, @@ -336,8 +332,7 @@ router.put("/", Utils.ensureAuthenticated, async (req, res, next) => { large_image: largeImage, api_key: apiKey, password: password ? hashSync(password, 10) : null, - challonge_api_key: challongeApiKey, - ...(dathostAllowed !== undefined && { dathost_allowed: dathostAllowed }) + challonge_api_key: challongeApiKey }; } else if (req.user && req.user.steam_id == steamId || req.user!.id == userId) { if (req.body[0].force_reset) { diff --git a/src/services/dathost.ts b/src/services/dathost.ts index ba4e9699..1c83d47e 100644 --- a/src/services/dathost.ts +++ b/src/services/dathost.ts @@ -5,61 +5,444 @@ */ import fetch, { FormData, Blob } from "node-fetch"; -import config from "config"; +import { execFile } from "child_process"; +import { promisify } from "util"; +import { mkdtemp, readFile, rm, writeFile } from "fs/promises"; +import path from "path"; +import os from "os"; +import Utils from "../utility/utils.js"; +import { DatHostConfig } from "../types/dathost/DatHostConfig.js"; +import { DatHostServerCreateOptions } from "../types/dathost/DatHostServerCreateOptions.js"; +import { DatHostServerInfo } from "../types/dathost/DatHostServerInfo.js"; +import { CreateAndStartResult } from "../types/dathost/CreateAndStartResult.js"; const BASE_URL = "https://dathost.net/api/0.1"; +const CSS_GITHUB_LATEST = + "https://api.github.com/repos/roflmuffin/CounterStrikeSharp/releases/latest"; +const MATCHZY_GITHUB_LATEST = + "https://api.github.com/repos/shobhit-pathak/MatchZy/releases/latest"; +const GET5_GITHUB_LATEST = + "https://api.github.com/repos/splewis/get5/releases/latest"; +const STEAMWORKS_GITHUB_LATEST = + "https://api.github.com/repos/KyleSanderson/SteamWorks/releases/latest"; +const POLL_INTERVAL_MS = 5000; +const POLL_TIMEOUT_MS = 300000; // 5 minutes -export interface DatHostServerCreateOptions { - name: string; - rcon: string; - steamGameServerLoginToken: string; - game?: string; +const execFileAsync = promisify(execFile); + +const configCache = new Map(); + +const DATHOST_LOCATION_IDS = [ + "prague", + "copenhagen", + "helsinki", + "strasbourg", + "dusseldorf", + "dublin", + "milan", + "amsterdam", + "oslo", + "warsaw", + "bucharest", + "barcelona", + "stockholm", + "bristol", + "beauharnois", + "new_york_city", + "los_angeles", + "miami", + "chicago", + "portland", + "dallas", + "atlanta", + "denver", + "sydney", + "hong_kong", + "mumbai", + "tokyo", + "auckland", + "singapore", + "seoul", + "buenos_aires", + "sao_paulo", + "santiago", + "johannesburg", + "istanbul", + "dubai" +] as const; + +function isValidDatHostLocationId(value: string): boolean { + return DATHOST_LOCATION_IDS.includes(value as (typeof DATHOST_LOCATION_IDS)[number]); } -export interface DatHostServerInfo { - id: string; - ip: string; - ports: { game: number }; - rcon: string; - status?: string; - booting?: boolean; - on?: boolean; +async function getDathostConfig(userId: number): Promise { + const cached = configCache.get(userId); + if (cached !== undefined) return cached; + + const { db } = await import("./db.js"); + const rows = await db.query( + "SELECT email, password, steam_game_server_login_token, shutdown_delay_seconds, preferred_location FROM dathost_config WHERE user_id = ? LIMIT 1", + [userId] + ); + const row = Array.isArray(rows) ? rows[0] : (rows as any)?.[0]; + if (!row) { + configCache.set(userId, null); + return null; + } + + const config = { + email: Utils.decrypt(row.email) ?? "", + password: Utils.decrypt(row.password) ?? "", + steamGameServerLoginToken: + Utils.decrypt(row.steam_game_server_login_token) ?? "", + shutdownDelaySeconds: row.shutdown_delay_seconds ?? 0, + preferredLocation: row.preferred_location ?? "" + }; + configCache.set(userId, config); + return config; } -export interface CreateAndStartResult { - id: string; - ip: string; - port: number; - rcon: string; +async function setDathostConfig( + userId: number, + email: string, + password: string, + steamToken: string, + shutdownDelay: number, + preferredLocation: string +): Promise { + if (!isValidDatHostLocationId(preferredLocation)) { + throw new Error("Invalid DatHost preferred location."); + } + + const { db } = await import("./db.js"); + const encEmail = Utils.encrypt(email); + const encPassword = Utils.encrypt(password); + const encToken = Utils.encrypt(steamToken); + + const existing = await db.query( + "SELECT id FROM dathost_config WHERE user_id = ? LIMIT 1", + [userId] + ); + const row = Array.isArray(existing) ? existing[0] : (existing as any)?.[0]; + + if (row) { + await db.query( + "UPDATE dathost_config SET email = ?, password = ?, steam_game_server_login_token = ?, shutdown_delay_seconds = ?, preferred_location = ? WHERE user_id = ?", + [encEmail, encPassword, encToken, shutdownDelay, preferredLocation, userId] + ); + } else { + await db.query( + "INSERT INTO dathost_config (user_id, email, password, steam_game_server_login_token, shutdown_delay_seconds, preferred_location) VALUES (?, ?, ?, ?, ?, ?)", + [userId, encEmail, encPassword, encToken, shutdownDelay, preferredLocation] + ); + } + + configCache.delete(userId); } -function getAuthHeader(): string { - const email = config.get("dathost.email"); - const password = config.get("dathost.password"); - const encoded = Buffer.from(`${email}:${password}`).toString("base64"); +async function getAuthHeader(userId: number): Promise { + const cfg = await getDathostConfig(userId); + if (!cfg) { + throw new Error(`DatHost credentials not configured for user ${userId}`); + } + const encoded = Buffer.from(`${cfg.email}:${cfg.password}`).toString( + "base64" + ); return `Basic ${encoded}`; } -function isDathostConfigured(): boolean { +async function isDathostConfigured(userId: number): Promise { try { - const email = config.get("dathost.email"); - const password = config.get("dathost.password"); - return Boolean(email && password); + const cfg = await getDathostConfig(userId); + return Boolean(cfg && cfg.email && cfg.password && cfg.preferredLocation); } catch { return false; } } + + +async function downloadToBuffer(url: string): Promise { + const res = await fetch(url, { redirect: "follow" }); + if (!res.ok) { + throw new Error(`Download failed: ${res.status} ${url}`); + } + return res.arrayBuffer(); +} + +function toArrayBuffer(buf: Buffer): ArrayBuffer { + return buf.buffer.slice( + buf.byteOffset, + buf.byteOffset + buf.byteLength + ) as ArrayBuffer; +} + +async function extractSteamworksExtensionFromTgz( + archiveData: ArrayBuffer +): Promise { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "steamworks-")); + const archivePath = path.join(tempDir, "package-lin.tgz"); + const extensionPath = path.join( + tempDir, + "package", + "addons", + "sourcemod", + "extensions", + "SteamWorks.ext.so" + ); + + try { + await writeFile(archivePath, Buffer.from(archiveData)); + await execFileAsync("tar", ["-xzf", archivePath, "-C", tempDir]); + const extensionBuf = await readFile(extensionPath); + return toArrayBuffer(extensionBuf); + } catch (err) { + throw new Error( + `Failed to extract SteamWorks.ext.so from tgz archive: ${(err as Error).message}` + ); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +} + +async function uploadFileToDathost( + userId: number, + serverId: string, + remotePath: string, + data: ArrayBuffer, + fileName: string +): Promise { + const contentType = fileName.endsWith(".zip") + ? "application/zip" + : fileName.endsWith(".tgz") || fileName.endsWith(".tar.gz") + ? "application/gzip" + : "application/octet-stream"; + const fd = new FormData(); + fd.append("file", new Blob([data], { type: contentType }), fileName); + + const res = await fetch( + `${BASE_URL}/game-servers/${encodeURIComponent(serverId)}/files/${encodeURIComponent(remotePath)}`, + { + method: "POST", + headers: { Authorization: await getAuthHeader(userId) }, + body: fd + } + ); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`DatHost upload failed (${remotePath}): ${res.status} ${text}`); + } +} + +async function unzipOnDathost( + userId: number, + serverId: string, + zipPath: string, + destination: string +): Promise { + const body = new URLSearchParams(); + body.append("destination", destination); + + const res = await fetch( + `${BASE_URL}/game-servers/${encodeURIComponent(serverId)}/unzip/${encodeURIComponent(zipPath)}`, + { + method: "POST", + headers: { + Authorization: await getAuthHeader(userId), + "Content-Type": "application/x-www-form-urlencoded" + }, + body: body.toString() + } + ); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`DatHost unzip failed (${zipPath}): ${res.status} ${text}`); + } +} + +interface GitHubAsset { + name: string; + browser_download_url: string; +} + +type SupportedDatHostGame = "cs2" | "csgo"; + +function resolveSupportedGame(game: string | undefined): SupportedDatHostGame { + const resolved = game ?? "cs2"; + if (resolved !== "cs2" && resolved !== "csgo") { + throw new Error(`Unsupported DatHost game: ${resolved}. Expected "cs2" or "csgo".`); + } + return resolved; +} + +async function installCs2Plugins(userId: number, serverId: string): Promise { + const releaseRes = await fetch(CSS_GITHUB_LATEST, { + headers: { "User-Agent": "G5API" } + }); + if (!releaseRes.ok) { + throw new Error( + `Failed to fetch CSS latest release: ${releaseRes.status}` + ); + } + const release = (await releaseRes.json()) as { assets: GitHubAsset[] }; + const cssAsset = release.assets.find((a) => + a.name.includes("with-runtime-linux") + ); + if (!cssAsset) { + throw new Error("CounterStrikeSharp with-runtime-linux asset not found in latest release"); + } + + console.log(`Downloading CounterStrikeSharp: ${cssAsset.name}`); + const cssBuf = await downloadToBuffer(cssAsset.browser_download_url); + console.log(`Uploading CounterStrikeSharp (${cssBuf.byteLength} bytes) to DatHost...`); + await uploadFileToDathost( + userId, + serverId, + "counterstrikesharp.zip", + cssBuf, + cssAsset.name + ); + console.log("Extracting CounterStrikeSharp..."); + await unzipOnDathost(userId, serverId, "counterstrikesharp.zip", "/"); + + const matchzyReleaseRes = await fetch(MATCHZY_GITHUB_LATEST, { + headers: { "User-Agent": "G5API" } + }); + if (!matchzyReleaseRes.ok) { + throw new Error( + `Failed to fetch MatchZy latest release: ${matchzyReleaseRes.status}` + ); + } + const matchzyRelease = (await matchzyReleaseRes.json()) as { assets: GitHubAsset[] }; + const matchzyAsset = matchzyRelease.assets.find( + (a) => a.name.endsWith(".zip") && !a.name.includes("with-cssharp") + ); + if (!matchzyAsset) { + throw new Error("MatchZy plugin-only zip asset not found in latest release"); + } + + console.log(`Downloading MatchZy: ${matchzyAsset.name}`); + const matchzyBuf = await downloadToBuffer(matchzyAsset.browser_download_url); + console.log(`Uploading MatchZy (${matchzyBuf.byteLength} bytes) to DatHost...`); + await uploadFileToDathost(userId, serverId, "matchzy.zip", matchzyBuf, matchzyAsset.name); + console.log("Extracting MatchZy..."); + await unzipOnDathost(userId, serverId, "matchzy.zip", "/"); + + console.log("Plugin installation complete."); +} + +async function installCsgoPlugins(userId: number, serverId: string): Promise { + const get5ReleaseRes = await fetch(GET5_GITHUB_LATEST, { + headers: { "User-Agent": "G5API" } + }); + if (!get5ReleaseRes.ok) { + throw new Error( + `Failed to fetch get5 latest release: ${get5ReleaseRes.status}` + ); + } + + const get5Release = (await get5ReleaseRes.json()) as { assets: GitHubAsset[] }; + const get5Asset = get5Release.assets.find((a) => a.name.endsWith(".zip")); + if (!get5Asset) { + throw new Error("get5 zip asset not found in latest release"); + } + + const steamworksReleaseRes = await fetch(STEAMWORKS_GITHUB_LATEST, { + headers: { "User-Agent": "G5API" } + }); + if (!steamworksReleaseRes.ok) { + throw new Error( + `Failed to fetch SteamWorks latest release: ${steamworksReleaseRes.status}` + ); + } + const steamworksRelease = (await steamworksReleaseRes.json()) as { assets: GitHubAsset[] }; + const steamworksAsset = + steamworksRelease.assets.find((a) => a.name === "package-lin.tgz") ?? + steamworksRelease.assets.find((a) => a.name.endsWith(".tgz")) ?? + steamworksRelease.assets.find((a) => a.name.endsWith(".tar.gz")) ?? + steamworksRelease.assets.find((a) => a.name.endsWith(".zip")); + if (!steamworksAsset) { + throw new Error("SteamWorks archive asset not found in latest release"); + } + + console.log(`Downloading SteamWorks: ${steamworksAsset.name}`); + const steamworksBuf = await downloadToBuffer(steamworksAsset.browser_download_url); + if ( + steamworksAsset.name.endsWith(".tgz") || + steamworksAsset.name.endsWith(".tar.gz") + ) { + console.log("Extracting SteamWorks extension locally from tgz..."); + const steamworksExtensionBuf = await extractSteamworksExtensionFromTgz( + steamworksBuf + ); + console.log( + `Uploading SteamWorks.ext.so (${steamworksExtensionBuf.byteLength} bytes) to DatHost...` + ); + await uploadFileToDathost( + userId, + serverId, + "addons/sourcemod/extensions/SteamWorks.ext.so", + steamworksExtensionBuf, + "SteamWorks.ext.so" + ); + } else { + console.log(`Uploading SteamWorks (${steamworksBuf.byteLength} bytes) to DatHost...`); + const steamworksRemoteArchivePath = steamworksAsset.name; + await uploadFileToDathost( + userId, + serverId, + steamworksRemoteArchivePath, + steamworksBuf, + steamworksAsset.name + ); + console.log("Extracting SteamWorks..."); + await unzipOnDathost(userId, serverId, steamworksRemoteArchivePath, "/"); + } + + console.log(`Downloading get5: ${get5Asset.name}`); + const get5Buf = await downloadToBuffer(get5Asset.browser_download_url); + console.log(`Uploading get5 (${get5Buf.byteLength} bytes) to DatHost...`); + await uploadFileToDathost(userId, serverId, "get5.zip", get5Buf, get5Asset.name); + console.log("Extracting get5..."); + await unzipOnDathost(userId, serverId, "get5.zip", "/"); + console.log("") + console.log("Plugin installation complete."); +} + +async function installPlugins( + userId: number, + serverId: string, + game: SupportedDatHostGame +): Promise { + if (game === "cs2") { + await installCs2Plugins(userId, serverId); + return; + } + await installCsgoPlugins(userId, serverId); +} + /** * Create a game server on DatHost (does not start it). */ -export async function createServer( +async function createServer( + userId: number, options: DatHostServerCreateOptions ): Promise { + const cfg = await getDathostConfig(userId); + const preferredLocation = cfg?.preferredLocation ?? ""; + if (!isValidDatHostLocationId(preferredLocation)) { + throw new Error("DatHost preferred location is missing or invalid."); + } + const body = new URLSearchParams(); - const game = options.game ?? "cs2"; + const game = resolveSupportedGame(options.game); body.append("name", options.name); body.append("game", game); + body.append("location", preferredLocation); + body.append("autostop", "true"); + body.append("autostop_minutes", "10"); // This can be a configurable value if (game === "cs2") { body.append("cs2_settings.rcon", options.rcon); @@ -74,12 +457,13 @@ export async function createServer( "csgo_settings.steam_game_server_login_token", options.steamGameServerLoginToken ); + body.append("csgo_settings.enable_sourcemod", "true"); } const res = await fetch(`${BASE_URL}/game-servers`, { method: "POST", headers: { - Authorization: getAuthHeader(), + Authorization: await getAuthHeader(userId), "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString() @@ -96,13 +480,13 @@ export async function createServer( /** * Start a game server on DatHost. */ -export async function startServer(dathostServerId: string): Promise { +async function startServer(userId: number, dathostServerId: string): Promise { const res = await fetch( `${BASE_URL}/game-servers/${encodeURIComponent(dathostServerId)}/start`, { method: "POST", headers: { - Authorization: getAuthHeader() + Authorization: await getAuthHeader(userId) } } ); @@ -118,7 +502,8 @@ export async function startServer(dathostServerId: string): Promise { /** * Get current game server details (ip, ports, status). */ -export async function getServer( +async function getServer( + userId: number, dathostServerId: string ): Promise { const res = await fetch( @@ -126,7 +511,7 @@ export async function getServer( { method: "GET", headers: { - Authorization: getAuthHeader() + Authorization: await getAuthHeader(userId) } } ); @@ -142,13 +527,13 @@ export async function getServer( /** * Stop a game server on DatHost. */ -export async function stopServer(dathostServerId: string): Promise { +async function stopServer(userId: number, dathostServerId: string): Promise { const res = await fetch( `${BASE_URL}/game-servers/${encodeURIComponent(dathostServerId)}/stop`, { method: "POST", headers: { - Authorization: getAuthHeader() + Authorization: await getAuthHeader(userId) } } ); @@ -162,13 +547,13 @@ export async function stopServer(dathostServerId: string): Promise { /** * Delete a game server on DatHost. */ -export async function deleteServer(dathostServerId: string): Promise { +async function deleteServer(userId: number, dathostServerId: string): Promise { const res = await fetch( `${BASE_URL}/game-servers/${encodeURIComponent(dathostServerId)}`, { method: "DELETE", headers: { - Authorization: getAuthHeader() + Authorization: await getAuthHeader(userId) } } ); @@ -181,147 +566,28 @@ export async function deleteServer(dathostServerId: string): Promise { } } -async function downloadToBuffer(url: string): Promise { - const res = await fetch(url, { redirect: "follow" }); - if (!res.ok) { - throw new Error(`Download failed: ${res.status} ${url}`); - } - return res.arrayBuffer(); -} - -async function uploadFileToDathost( - serverId: string, - remotePath: string, - data: ArrayBuffer, - fileName: string -): Promise { - const fd = new FormData(); - fd.append("file", new Blob([data], { type: "application/zip" }), fileName); - - const res = await fetch( - `${BASE_URL}/game-servers/${encodeURIComponent(serverId)}/files/${encodeURIComponent(remotePath)}`, - { - method: "POST", - headers: { Authorization: getAuthHeader() }, - body: fd - } - ); - - if (!res.ok) { - const text = await res.text(); - throw new Error(`DatHost upload failed (${remotePath}): ${res.status} ${text}`); - } -} - -async function unzipOnDathost( - serverId: string, - zipPath: string, - destination: string -): Promise { - const body = new URLSearchParams(); - body.append("destination", destination); - - const res = await fetch( - `${BASE_URL}/game-servers/${encodeURIComponent(serverId)}/unzip/${encodeURIComponent(zipPath)}`, - { - method: "POST", - headers: { - Authorization: getAuthHeader(), - "Content-Type": "application/x-www-form-urlencoded" - }, - body: body.toString() - } - ); - - if (!res.ok) { - const text = await res.text(); - throw new Error(`DatHost unzip failed (${zipPath}): ${res.status} ${text}`); - } -} - -const CSS_GITHUB_LATEST = - "https://api.github.com/repos/roflmuffin/CounterStrikeSharp/releases/latest"; -const MATCHZY_GITHUB_LATEST = - "https://api.github.com/repos/shobhit-pathak/MatchZy/releases/latest"; - -interface GitHubAsset { - name: string; - browser_download_url: string; -} - -async function installPlugins(serverId: string): Promise { - const releaseRes = await fetch(CSS_GITHUB_LATEST, { - headers: { "User-Agent": "G5API" } - }); - if (!releaseRes.ok) { - throw new Error( - `Failed to fetch CSS latest release: ${releaseRes.status}` - ); - } - const release = (await releaseRes.json()) as { assets: GitHubAsset[] }; - const cssAsset = release.assets.find((a) => - a.name.includes("with-runtime-linux") - ); - if (!cssAsset) { - throw new Error("CounterStrikeSharp with-runtime-linux asset not found in latest release"); - } - - console.log(`Downloading CounterStrikeSharp: ${cssAsset.name}`); - const cssBuf = await downloadToBuffer(cssAsset.browser_download_url); - console.log(`Uploading CounterStrikeSharp (${cssBuf.byteLength} bytes) to DatHost...`); - await uploadFileToDathost(serverId, "counterstrikesharp.zip", cssBuf, cssAsset.name); - console.log("Extracting CounterStrikeSharp..."); - await unzipOnDathost(serverId, "counterstrikesharp.zip", "/"); - - const matchzyReleaseRes = await fetch(MATCHZY_GITHUB_LATEST, { - headers: { "User-Agent": "G5API" } - }); - if (!matchzyReleaseRes.ok) { - throw new Error( - `Failed to fetch MatchZy latest release: ${matchzyReleaseRes.status}` - ); - } - const matchzyRelease = (await matchzyReleaseRes.json()) as { assets: GitHubAsset[] }; - const matchzyAsset = matchzyRelease.assets.find( - (a) => a.name.endsWith(".zip") && !a.name.includes("with-cssharp") - ); - if (!matchzyAsset) { - throw new Error("MatchZy plugin-only zip asset not found in latest release"); - } - - console.log(`Downloading MatchZy: ${matchzyAsset.name}`); - const matchzyBuf = await downloadToBuffer(matchzyAsset.browser_download_url); - console.log(`Uploading MatchZy (${matchzyBuf.byteLength} bytes) to DatHost...`); - await uploadFileToDathost(serverId, "matchzy.zip", matchzyBuf, matchzyAsset.name); - console.log("Extracting MatchZy..."); - await unzipOnDathost(serverId, "matchzy.zip", "/"); - - console.log("Plugin installation complete."); -} - -const POLL_INTERVAL_MS = 5000; -const POLL_TIMEOUT_MS = 300000; // 5 minutes - /** * Create a server, start it, and poll until it is ready (has ip and game port). * Returns connection details for use in game_server row and RCON. */ -export async function createAndStartServer( +async function createAndStartServer( + userId: number, options: DatHostServerCreateOptions ): Promise { - const server = await createServer(options); + const game = resolveSupportedGame(options.game); + const server = await createServer(userId, options); const id = server.id; const rcon = options.rcon; - await installPlugins(id); - await startServer(id); + await installPlugins(userId, id, game); + await startServer(userId, id); const deadline = Date.now() + POLL_TIMEOUT_MS; let info: DatHostServerInfo; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); - info = await getServer(id); + info = await getServer(userId, id); if (info.ip && info.ports?.game && info.on && !info.booting) { return { id, @@ -337,41 +603,48 @@ export async function createAndStartServer( ); } -export { isDathostConfigured }; - /** * Release a game server after match end or cancel: set in_use=0 for normal servers, * or for managed (DatHost) servers: stop and delete on DatHost, null match.server_id, delete game_server row. * Safe to call with null serverId (no-op). */ -export async function releaseManagedServer( +async function releaseManagedServer( serverId: number | null | undefined ): Promise { if (serverId == null) return; const { db } = await import("./db.js"); const rows = await db.query( - "SELECT id, dathost_server_id, is_managed FROM game_server WHERE id = ?", + "SELECT id, user_id, dathost_server_id, is_managed FROM game_server WHERE id = ?", [serverId] ); const row = Array.isArray(rows) ? rows[0] : (rows as any)?.[0]; if (!row) return; if (row.is_managed && row.dathost_server_id) { - const delaySeconds = - (config.has("dathost.shutdown_delay_seconds") && - config.get("dathost.shutdown_delay_seconds")) || - 0; + const ownerUserId = Number(row.user_id); + const cfg = Number.isFinite(ownerUserId) && ownerUserId > 0 + ? await getDathostConfig(ownerUserId) + : null; + const delaySeconds = cfg?.shutdownDelaySeconds || 0; if (delaySeconds > 0) { await new Promise((r) => setTimeout(r, delaySeconds * 1000)); } try { - await stopServer(row.dathost_server_id); + if (ownerUserId > 0) { + await stopServer(ownerUserId, row.dathost_server_id); + } else { + throw new Error("Managed DatHost server has no owner user_id"); + } } catch (e) { console.error("DatHost stopServer error:", e); } try { - await deleteServer(row.dathost_server_id); + if (ownerUserId > 0) { + await deleteServer(ownerUserId, row.dathost_server_id); + } else { + throw new Error("Managed DatHost server has no owner user_id"); + } } catch (e) { console.error("DatHost deleteServer error:", e); } @@ -385,3 +658,18 @@ export async function releaseManagedServer( ]); } } + +export { + DATHOST_LOCATION_IDS, + isValidDatHostLocationId, + getDathostConfig, + setDathostConfig, + createServer, + startServer, + getServer, + stopServer, + deleteServer, + createAndStartServer, + isDathostConfigured, + releaseManagedServer +}; diff --git a/src/services/queue.ts b/src/services/queue.ts index 3c432c56..24e2ba6b 100644 --- a/src/services/queue.ts +++ b/src/services/queue.ts @@ -8,13 +8,36 @@ import { db } from "./db.js"; import GameServer from "../utility/serverrcon.js"; import GlobalEmitter from "../utility/emitter.js"; import { generate } from "randomstring"; +import { + createAndStartServer, + isDathostConfigured, + getDathostConfig, + stopServer, + deleteServer +} from "./dathost.js"; const redis = createClient({ url: config.get("server.redisUrl"), }); const DEFAULT_TTL_SECONDS: number = config.get("server.queueTTL") == 0 ? 3600 : config.get("server.queueTTL"); +type QueueGame = "cs2" | "csgo"; +const DEFAULT_QUEUE_GAME: QueueGame = "cs2"; + +export class QueueOwnerDatHostConfigMissingError extends Error { + constructor() { + super("Queue owner has not configured DatHost credentials."); + this.name = "QueueOwnerDatHostConfigMissingError"; + } +} export class QueueService { - static async createQueue(ownerId: string, nickname: string, maxPlayers: number = 10, isPrivate: boolean = false, ttlSeconds: number = DEFAULT_TTL_SECONDS): Promise { + static async createQueue( + ownerId: string, + nickname: string, + maxPlayers: number = 10, + isPrivate: boolean = false, + game: QueueGame = DEFAULT_QUEUE_GAME, + ttlSeconds: number = DEFAULT_TTL_SECONDS + ): Promise { let slug: string; let key: string; let attempts: number = 0; @@ -43,7 +66,8 @@ export class QueueService { ownerId, maxSize: maxPlayers, isPrivate: isPrivate, - currentPlayers: 1 + currentPlayers: 1, + game: normalizeQueueGame(game) }; await redis.sAdd('queues', slug); @@ -57,18 +81,17 @@ export class QueueService { /** * Create a match record for a queue after teams have been created. - * - Picks an available server (owned by user or public) and marks it in_use - * - Uses the owner's `map_list` if present, otherwise falls back to default CS2 pool + * Behaviour depends on `server.serverProvider` config: + * - "local": pick an available game_server from DB (existing flow) + * - "dathost": provision a server on DatHost on the fly */ static async createMatchFromQueue( slug: string, teamIds: number[] ): Promise { const meta = await getQueueMetaOrThrow(slug); - // Generate API key for the match (used when preparing the server) const apiKey = generate({ length: 24, capitalization: "uppercase" }); - // Try to load user's map_list if available let mapPool: string[] = []; let ownerUserId: number | null = await getUserIdFromMetaSlug(slug); try { @@ -86,7 +109,6 @@ export class QueueService { mapPool = (config.get("defaultMaps") as { map_name: string }[]).map(m => m.map_name); } - // Build base match object const baseMatch: any = { user_id: ownerUserId || 0, team1_id: teamIds[0] || null, @@ -104,7 +126,28 @@ export class QueueService { players_per_team: meta.maxSize/2 }; - // Fetch candidate servers (include connection info) + const provider: string = config.has("server.serverProvider") + ? (config.get("server.serverProvider") as string) + : "local"; + + if (provider === "dathost") { + return this.createMatchWithDathost(slug, teamIds, meta, baseMatch, apiKey, ownerUserId); + } + + return this.createMatchWithLocalServer(slug, teamIds, meta, baseMatch, apiKey, ownerUserId); + } + + /** + * Local server provider: iterate candidate game_server rows and RCON into them. + */ + private static async createMatchWithLocalServer( + slug: string, + teamIds: number[], + meta: QueueDescriptor, + baseMatch: any, + apiKey: string, + ownerUserId: number | null + ): Promise { let candidates: RowDataPacket[] = []; try { if (ownerUserId && ownerUserId > 0) { @@ -120,7 +163,7 @@ export class QueueService { } catch (err) { candidates = []; } - console.log(`Found ${candidates.length} candidates servers for match from queue ${slug}.`); + console.log(`Found ${candidates.length} candidate servers for match from queue ${slug}.`); for (const cand of candidates) { try { @@ -213,6 +256,117 @@ export class QueueService { } } + /** + * DatHost server provider: provision a server via DatHost API, insert a managed + * game_server row, create the match, and load it onto the server. + */ + private static async createMatchWithDathost( + slug: string, + teamIds: number[], + meta: QueueDescriptor, + baseMatch: any, + apiKey: string, + ownerUserId: number | null + ): Promise { + if (!ownerUserId || ownerUserId <= 0) { + throw new QueueOwnerDatHostConfigMissingError(); + } + + if (!(await isDathostConfigured(ownerUserId))) { + throw new QueueOwnerDatHostConfigMissingError(); + } + + const rconPassword = generate({ length: 16, capitalization: "uppercase" }); + const dathostCfg = await getDathostConfig(ownerUserId); + const steamToken = dathostCfg?.steamGameServerLoginToken ?? ""; + + let dathostResult: { id: string; ip: string; port: number; rcon: string }; + try { + dathostResult = await createAndStartServer(ownerUserId, { + name: `G5-Queue-${Date.now()}`, + rcon: rconPassword, + steamGameServerLoginToken: steamToken, + game: normalizeQueueGame(meta.game) + }); + } catch (e) { + console.error("DatHost createAndStartServer failed for queue:", e); + return null; + } + + let newServerId: number | null = null; + const rconEncrypted = Utils.encrypt(dathostResult.rcon); + try { + const displayName = `DatHost-Queue-${dathostResult.id.slice(0, 8)}`; + const insertServerRes = await db.query( + "INSERT INTO game_server (user_id, ip_string, port, rcon_password, display_name, public_server, flag, gotv_port, dathost_server_id, is_managed) VALUES (?,?,?,?,?,?,?,?,?,?)", + [ + ownerUserId || 0, + dathostResult.ip, + dathostResult.port, + rconEncrypted, + displayName, + 0, + "", + null, + dathostResult.id, + 1 + ] + ); + newServerId = (insertServerRes as any).insertId; + } catch (e) { + console.error("Failed to insert managed game_server row:", e); + await cleanupDathostServer(dathostResult.id, ownerUserId); + return null; + } + + try { + const newServer = new GameServer(dathostResult.ip, dathostResult.port, rconEncrypted!); + + const insertSet = await db.buildUpdateStatement({ ...baseMatch, server_id: newServerId }) as any; + const insertRes: any = await db.query("INSERT INTO `match` SET ?", [insertSet]); + const matchId = (insertRes as any).insertId; + + await db.query("UPDATE game_server SET in_use = 1 WHERE id = ?", [newServerId]); + + try { + const get5Version: string = await newServer.getGet5Version(); + await db.query("UPDATE `match` SET plugin_version = ? WHERE id = ?", [get5Version, matchId]); + } catch (err) { + // ignore version retrieval errors + } + + const prepared = await newServer.prepareGet5Match( + config.get("server.apiURL") + "/matches/" + matchId + "/config", + apiKey + ); + + if (!prepared) { + await db.query("DELETE FROM match_spectator WHERE match_id = ?", [matchId]); + await db.query("DELETE FROM match_cvar WHERE match_id = ?", [matchId]); + await db.query("DELETE FROM `match` WHERE id = ?", [matchId]); + await db.query("DELETE FROM game_server WHERE id = ?", [newServerId]); + await cleanupDathostServer(dathostResult.id, ownerUserId); + return null; + } + + await this.deleteQueue(slug, meta.ownerId!); + (GlobalEmitter as any).emit('queueUpdate', { + slug, + action: 'match_created', + match: { matchId, serverId: newServerId } + }); + (GlobalEmitter as any).emit('match:created', { matchId, serverId: newServerId, teams: teamIds, slug }); + return matchId; + } catch (err) { + console.error("DatHost queue match creation failed:", err); + try { + await db.query("DELETE FROM game_server WHERE id = ?", [newServerId]); + } catch (_) { /* ignore */ } + await cleanupDathostServer(dathostResult.id, ownerUserId); + return null; + } + } + static async deleteQueue( slug: string, requestorSteamId: string, @@ -324,6 +478,7 @@ export class QueueService { const meta: QueueDescriptor = JSON.parse(metaRaw); meta.currentPlayers = await redis.lLen(`queue:${slug}`); + meta.game = normalizeQueueGame(meta.game); if (role === 'admin' || role === 'super_admin' || meta.ownerId === requestorSteamId || meta.isPrivate === false) { descriptors.push(meta); @@ -545,6 +700,25 @@ export class QueueService { } +async function cleanupDathostServer( + dathostServerId: string, + ownerUserId: number | null +): Promise { + if (!ownerUserId || ownerUserId <= 0) { + return; + } + try { + await stopServer(ownerUserId, dathostServerId); + } catch (e) { + console.error("DatHost stopServer cleanup error:", e); + } + try { + await deleteServer(ownerUserId, dathostServerId); + } catch (e) { + console.error("DatHost deleteServer cleanup error:", e); + } +} + async function getUserIdFromMetaSlug(slug: string): Promise { const meta = await getQueueMetaOrThrow(slug); let ownerUserId: number | null = 0; @@ -579,7 +753,13 @@ async function getQueueMetaOrThrow(slug: string): Promise { throw new Error(`Queue metadata missing for ${slug}.`); } - return JSON.parse(metaRaw); + const meta: QueueDescriptor = JSON.parse(metaRaw); + meta.game = normalizeQueueGame(meta.game); + return meta; +} + +function normalizeQueueGame(game?: string): QueueGame { + return game === "csgo" ? "csgo" : DEFAULT_QUEUE_GAME; } export default QueueService; \ No newline at end of file diff --git a/src/types/User.ts b/src/types/User.ts index 5bcf36bd..70178b20 100644 --- a/src/types/User.ts +++ b/src/types/User.ts @@ -8,5 +8,4 @@ export interface User { medium_image: string large_image: string api_key: string - dathost_allowed?: boolean | number } \ No newline at end of file diff --git a/src/types/dathost/CreateAndStartResult.ts b/src/types/dathost/CreateAndStartResult.ts new file mode 100644 index 00000000..f47215b1 --- /dev/null +++ b/src/types/dathost/CreateAndStartResult.ts @@ -0,0 +1,6 @@ +export interface CreateAndStartResult { + id: string; + ip: string; + port: number; + rcon: string; +} \ No newline at end of file diff --git a/src/types/dathost/DatHostConfig.ts b/src/types/dathost/DatHostConfig.ts new file mode 100644 index 00000000..0e6f465a --- /dev/null +++ b/src/types/dathost/DatHostConfig.ts @@ -0,0 +1,7 @@ +export interface DatHostConfig { + email: string; + password: string; + steamGameServerLoginToken: string; + shutdownDelaySeconds: number; + preferredLocation: string; +} \ No newline at end of file diff --git a/src/types/dathost/DatHostServerCreateOptions.ts b/src/types/dathost/DatHostServerCreateOptions.ts new file mode 100644 index 00000000..98580e49 --- /dev/null +++ b/src/types/dathost/DatHostServerCreateOptions.ts @@ -0,0 +1,6 @@ +export interface DatHostServerCreateOptions { + name: string; + rcon: string; + steamGameServerLoginToken: string; + game?: "cs2" | "csgo"; +} \ No newline at end of file diff --git a/src/types/dathost/DatHostServerInfo.ts b/src/types/dathost/DatHostServerInfo.ts new file mode 100644 index 00000000..1692ef19 --- /dev/null +++ b/src/types/dathost/DatHostServerInfo.ts @@ -0,0 +1,9 @@ +export interface DatHostServerInfo { + id: string; + ip: string; + ports: { game: number }; + rcon: string; + status?: string; + booting?: boolean; + on?: boolean; +} \ No newline at end of file diff --git a/src/types/queues/QueueDescriptor.ts b/src/types/queues/QueueDescriptor.ts index 69ca733e..7aeadc8e 100644 --- a/src/types/queues/QueueDescriptor.ts +++ b/src/types/queues/QueueDescriptor.ts @@ -6,4 +6,5 @@ export interface QueueDescriptor { maxSize: number; // Max number of players allowed in the queue isPrivate?: boolean; // Optional flag for visibility currentPlayers: number; // Current number of players in the queue + game?: "cs2" | "csgo"; // Selected game for this queue } \ No newline at end of file diff --git a/src/types/users/UserObject.ts b/src/types/users/UserObject.ts index 7f21fb71..ccd7fc29 100644 --- a/src/types/users/UserObject.ts +++ b/src/types/users/UserObject.ts @@ -9,5 +9,4 @@ export interface UserObject { api_key?: string | undefined | null, challonge_api_key?: string | undefined | null, password?: string | null | undefined, - dathost_allowed?: boolean, } \ No newline at end of file diff --git a/src/utility/auth.ts b/src/utility/auth.ts index d38659de..831696f9 100644 --- a/src/utility/auth.ts +++ b/src/utility/auth.ts @@ -118,7 +118,6 @@ async function returnStrategy(identifier: any, profile: any, done: any) { medium_image: profile.photos[1].value, large_image: profile.photos[2].value, api_key: curUser[0].id + ":" + Utils.decrypt(curUser[0].api_key), - dathost_allowed: curUser[0].dathost_allowed, } as SessionUser); } catch (err) { console.log(profile.toString()); @@ -155,7 +154,6 @@ passport.use('local-login', new LocalStrategy(async (username, password, done) = medium_image: curUser[0].medium_image, large_image: curUser[0].large_image, api_key: curUser[0].id + ":" + Utils.decrypt(curUser[0].api_key), - dathost_allowed: curUser[0].dathost_allowed, } as SessionUser); } else { return done(null, false, {message: "Invalid username or password."}); @@ -229,7 +227,6 @@ passport.use('local-register', medium_image: curUser[0].medium_image, large_image: curUser[0].large_image, api_key: curUser[0].id + ":" + Utils.decrypt(curUser[0].api_key), - dathost_allowed: curUser[0].dathost_allowed, } as SessionUser); } } catch (e) { diff --git a/src/utility/serverrcon.ts b/src/utility/serverrcon.ts index b07909e4..ab997300 100644 --- a/src/utility/serverrcon.ts +++ b/src/utility/serverrcon.ts @@ -1,8 +1,8 @@ import config from "config"; import Utils from "./utils.js"; -import { Rcon } from "dathost-rcon-client"; import fetch from "node-fetch"; import { compare } from "compare-versions"; +import { createConnection, Socket } from "node:net"; import { SteamApiResponse } from "../types/serverrcon/SteamApiResponse.js"; const RCON_TIMEOUT_MS = config.has("server.serverPingTimeoutMs") @@ -17,7 +17,9 @@ class ServerRcon { host: string; port: number; password: string; - rcon: Rcon; + private static readonly AUTH_PACKET_TYPE = 3; + private static readonly EXEC_PACKET_TYPE = 2; + private static readonly COMMAND_RESPONSE_IDLE_MS = 75; /** * Represents a game server. @@ -30,37 +32,231 @@ class ServerRcon { this.host = hostName; this.port = portNumber; this.password = Utils.decrypt(rconPassword)!; - this.rcon = new Rcon({ - host: this.host, - port: this.port, - password: this.password, - }); } async execute(commandString: string): Promise { - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error(`RCON timeout after ${RCON_TIMEOUT_MS}ms`)), RCON_TIMEOUT_MS); + try { + const response = await this.executeWithSocket(commandString); + return response; + } catch (error) { + console.error("[RCON] Got error: " + error); + throw error; + } + } + + private buildPacket(requestId: number, packetType: number, body: string): Buffer { + const bodyBuffer = Buffer.from(body, "utf8"); + const size = 4 + 4 + bodyBuffer.length + 2; + const packet = Buffer.alloc(size + 4); + packet.writeInt32LE(size, 0); + packet.writeInt32LE(requestId, 4); + packet.writeInt32LE(packetType, 8); + bodyBuffer.copy(packet, 12); + packet.writeInt16LE(0, 12 + bodyBuffer.length); + return packet; + } + + private executeWithSocket(commandString: string): Promise { + return new Promise((resolve, reject) => { + const socket: Socket = createConnection({ + host: this.host, + port: this.port, + }); + + const authRequestId = Math.floor(Math.random() * 2147483647); + const commandRequestId = Math.floor(Math.random() * 2147483647); + let commandSent = false; + let authSucceeded = false; + let settled = false; + let receivedCommandResponse = false; + let readBuffer = Buffer.alloc(0); + const responseParts: string[] = []; + + let responseIdleTimer: NodeJS.Timeout | null = null; + const hardTimeout = setTimeout(() => { + if (settled) return; + settled = true; + socket.destroy(); + reject(new Error(`RCON timeout after ${RCON_TIMEOUT_MS}ms`)); + }, RCON_TIMEOUT_MS); + + const cleanup = () => { + clearTimeout(hardTimeout); + if (responseIdleTimer) { + clearTimeout(responseIdleTimer); + responseIdleTimer = null; + } + }; + + const finalizeSuccess = () => { + if (settled) return; + settled = true; + cleanup(); + socket.end(); + resolve(responseParts.join("").trimEnd()); + }; + + const finalizeError = (err: unknown) => { + if (settled) return; + settled = true; + cleanup(); + socket.destroy(); + reject(err instanceof Error ? err : new Error(String(err))); + }; + + const bumpResponseIdleWindow = () => { + if (responseIdleTimer) { + clearTimeout(responseIdleTimer); + } + responseIdleTimer = setTimeout( + finalizeSuccess, + ServerRcon.COMMAND_RESPONSE_IDLE_MS + ); + }; + + socket.on("connect", () => { + socket.write( + this.buildPacket( + authRequestId, + ServerRcon.AUTH_PACKET_TYPE, + this.password + ) + ); + }); + + socket.on("data", (chunk: Buffer) => { + readBuffer = Buffer.concat([readBuffer, chunk]); + + while (readBuffer.length >= 4) { + const packetSize = readBuffer.readInt32LE(0); + const fullPacketSize = packetSize + 4; + + if (packetSize < 10) { + finalizeError(new Error("Received malformed RCON packet.")); + return; + } + + if (readBuffer.length < fullPacketSize) { + return; + } + + const packet = readBuffer.subarray(0, fullPacketSize); + readBuffer = readBuffer.subarray(fullPacketSize); + + const responseId = packet.readInt32LE(4); + const bodyLength = packetSize - 10; + const body = packet.toString("utf8", 12, 12 + bodyLength); + + if (!authSucceeded) { + if (responseId === -1) { + finalizeError(new Error("RCON authentication error.")); + return; + } + if (responseId === authRequestId && !commandSent) { + authSucceeded = true; + commandSent = true; + socket.write( + this.buildPacket( + commandRequestId, + ServerRcon.EXEC_PACKET_TYPE, + commandString + ) + ); + } + continue; + } + + if (responseId === commandRequestId) { + receivedCommandResponse = true; + if (body.length > 0) { + responseParts.push(body); + } + bumpResponseIdleWindow(); + } + } + }); + + socket.on("error", (err) => { + finalizeError(err); + }); + + socket.on("close", () => { + if (settled) return; + if (receivedCommandResponse) { + finalizeSuccess(); + return; + } + finalizeError( + new Error("RCON socket closed before receiving command response.") + ); + }); }); - const executePromise = (async () => { - try { - await this.rcon.connect(); - const response = await this.rcon.send(commandString); - this.rcon.disconnect(); - return response; - } catch (error) { - console.error("[RCON] Got error: " + error); - throw error; + } + + private extractFirstJsonObject(rawResponse: string): string { + const trimmed = rawResponse.trim(); + if (!trimmed) { + throw new Error("Received empty RCON response while JSON was expected."); + } + + const firstBraceIdx = trimmed.indexOf("{"); + if (firstBraceIdx === -1) { + throw new Error(`No JSON object found in RCON response: ${trimmed}`); + } + + let depth = 0; + let inString = false; + let isEscaping = false; + let endBraceIdx = -1; + + for (let i = firstBraceIdx; i < trimmed.length; i++) { + const ch = trimmed[i]; + + if (inString) { + if (isEscaping) { + isEscaping = false; + continue; + } + if (ch === "\\") { + isEscaping = true; + continue; + } + if (ch === "\"") { + inString = false; + } + continue; } - })(); - try { - return await Promise.race([executePromise, timeoutPromise]); - } catch (err) { - try { - this.rcon.disconnect(); - } catch (_) { - // ignore disconnect errors on timeout + + if (ch === "\"") { + inString = true; + continue; } - throw err; + + if (ch === "{") { + depth++; + } else if (ch === "}") { + depth--; + if (depth === 0) { + endBraceIdx = i; + break; + } + } + } + + if (endBraceIdx === -1) { + throw new Error(`Incomplete JSON object in RCON response: ${trimmed}`); + } + + return trimmed.slice(firstBraceIdx, endBraceIdx + 1); + } + + private parseRconJsonResponse(rawResponse: string): T { + const normalized = rawResponse.trim(); + try { + return JSON.parse(normalized) as T; + } catch (_) { + const extracted = this.extractFirstJsonObject(normalized); + return JSON.parse(extracted) as T; } } @@ -76,7 +272,7 @@ class ServerRcon { if (get5Status.includes("Unknown command")) { return "unknown"; } - let get5JsonStatus = await JSON.parse(get5Status); + let get5JsonStatus = this.parseRconJsonResponse<{ plugin_version: string }>(get5Status); return get5JsonStatus.plugin_version; } @@ -99,7 +295,7 @@ class ServerRcon { return false; } } - let get5JsonStatus = await JSON.parse(get5Status); + let get5JsonStatus = this.parseRconJsonResponse<{ gamestate: number | string }>(get5Status); if (get5JsonStatus.gamestate != 0) { console.log("Server already has a match setup."); return false;