Skip to content

Commit d2669cc

Browse files
committed
new server status tracking via extension
1 parent 685a608 commit d2669cc

6 files changed

Lines changed: 379 additions & 0 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- Add columns for data provided by the gokz-realtime-status extension
2+
-- that aren't already in the servers table.
3+
4+
ALTER TABLE servers
5+
ADD COLUMN mm_version VARCHAR(50) DEFAULT NULL COMMENT 'Metamod:Source version' AFTER version,
6+
ADD COLUMN sm_version VARCHAR(50) DEFAULT NULL COMMENT 'SourceMod version' AFTER mm_version,
7+
ADD COLUMN gokz_loaded TINYINT DEFAULT NULL COMMENT 'Whether GOKZ plugin is loaded (1=yes, 0=no)' AFTER sm_version;

db/schema.sql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ CREATE TABLE IF NOT EXISTS servers (
1717
maxplayers INT DEFAULT 0,
1818
players_list JSON DEFAULT NULL COMMENT 'JSON array of current players on server',
1919
version VARCHAR(50) DEFAULT '',
20+
mm_version VARCHAR(50) DEFAULT NULL COMMENT 'Metamod:Source version',
21+
sm_version VARCHAR(50) DEFAULT NULL COMMENT 'SourceMod version',
22+
gokz_loaded TINYINT DEFAULT NULL COMMENT 'Whether GOKZ plugin is loaded (1=yes, 0=no)',
2023
hostname VARCHAR(255) DEFAULT NULL COMMENT 'Server hostname from RCON',
2124
os VARCHAR(100) DEFAULT NULL COMMENT 'Server OS/type from RCON',
2225
secure TINYINT DEFAULT NULL COMMENT 'VAC secure status: 1=secure, 0=insecure',

src/api/serverStatus.js

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
const express = require("express");
2+
const router = express.Router();
3+
const pool = require("../db");
4+
const logger = require("../utils/logger");
5+
const { isValidIP, sanitizeMapName, sanitizePlayerName } = require("../utils/validators");
6+
const { markServerLive } = require("../services/liveServers");
7+
const { deleteCache } = require("../db/redis");
8+
const {
9+
emitServerUpdate,
10+
emitServerStatusChange,
11+
emitPlayerUpdate,
12+
emitMapUpdate,
13+
} = require("../services/websocket");
14+
15+
// In-memory state for session/map tracking (mirrors updater's tracking)
16+
const previousServerStates = new Map();
17+
const currentMapStates = new Map();
18+
19+
/**
20+
* POST /servers/status
21+
*
22+
* Receives live server data from the gokz-realtime-status extension.
23+
* Authenticated via adminAuth middleware (API key, IP whitelist, or localhost).
24+
*
25+
* Expected payload (from extension BuildPayload):
26+
* {
27+
* server: { hostname, ip, port, os, map, players, max_players, mm_version, sm_version, gokz_loaded, plugins: [...] },
28+
* players: [{ steamid, name, ip, time_on_server, in_game, gokz?: { mode, timer_running, paused, time, course, teleports } }]
29+
* }
30+
*/
31+
router.post("/", async (req, res) => {
32+
try {
33+
const payload = req.body;
34+
35+
if (!payload || !payload.server) {
36+
return res.status(400).json({ error: "Missing server data" });
37+
}
38+
39+
const srv = payload.server;
40+
const ip = srv.ip;
41+
const port = parseInt(srv.port, 10);
42+
43+
if (!ip || !isValidIP(ip) || !port || port < 1 || port > 65535) {
44+
return res.status(400).json({ error: "Invalid server ip/port" });
45+
}
46+
47+
// Look up this server in our config to get game type and metadata
48+
const [configRows] = await pool.query(
49+
"SELECT game, region, domain, api_id, kzt_id, tickrate FROM servers WHERE ip = ? AND port = ?",
50+
[ip, port],
51+
);
52+
53+
if (configRows.length === 0) {
54+
return res.status(404).json({ error: "Server not registered" });
55+
}
56+
57+
const serverConfig = configRows[0];
58+
const game = serverConfig.game;
59+
60+
// Mark server as receiving live data so updater skips external queries
61+
markServerLive(ip, port);
62+
63+
// Get previous server status for change detection
64+
const [prevStatus] = await pool.query(
65+
"SELECT status, map, player_count FROM servers WHERE ip = ? AND port = ?",
66+
[ip, port],
67+
);
68+
const previousServer = prevStatus[0] || null;
69+
70+
const sanitizedMap = srv.map ? sanitizeMapName(srv.map) : "";
71+
const playerCount = parseInt(srv.players, 10) || 0;
72+
const maxPlayers = parseInt(srv.max_players, 10) || 0;
73+
const botCount = parseInt(srv.bot_count, 10) || 0;
74+
const tickrate = parseInt(srv.tickrate, 10) || serverConfig.tickrate || null;
75+
76+
// strip IPs before storing in players_list
77+
const extensionPlayers = Array.isArray(payload.players) ? payload.players : [];
78+
const playersListForStorage = extensionPlayers
79+
.filter((p) => p.steamid && p.in_game)
80+
.map((p) => ({
81+
name: sanitizePlayerName(p.name) || "Unknown",
82+
steamid: p.steamid,
83+
time: p.time_on_server ? `${Math.floor(p.time_on_server)}s` : null,
84+
gokz: p.gokz || null,
85+
}));
86+
87+
await pool.query(
88+
`INSERT INTO servers (ip, port, game, version, mm_version, sm_version, gokz_loaded, hostname, os, secure, status, map, player_count, maxplayers, bot_count, players_list, region, domain, api_id, kzt_id, tickrate)
89+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
90+
ON DUPLICATE KEY UPDATE version=VALUES(version), mm_version=VALUES(mm_version), sm_version=VALUES(sm_version), gokz_loaded=VALUES(gokz_loaded), hostname=VALUES(hostname), os=VALUES(os), secure=VALUES(secure), status=1, map=VALUES(map), player_count=VALUES(player_count), maxplayers=VALUES(maxplayers), bot_count=VALUES(bot_count), players_list=VALUES(players_list), tickrate=COALESCE(VALUES(tickrate), tickrate), last_update=NOW()`,
91+
[
92+
ip,
93+
port,
94+
game,
95+
srv.version || "",
96+
srv.mm_version || null,
97+
srv.sm_version || null,
98+
srv.gokz_loaded != null ? (srv.gokz_loaded ? 1 : 0) : null,
99+
srv.hostname || null,
100+
srv.os || null,
101+
srv.secure != null ? (srv.secure ? 1 : 0) : null,
102+
sanitizedMap,
103+
playerCount,
104+
maxPlayers,
105+
botCount,
106+
JSON.stringify(playersListForStorage),
107+
serverConfig.region,
108+
serverConfig.domain,
109+
serverConfig.api_id,
110+
serverConfig.kzt_id,
111+
tickrate,
112+
],
113+
);
114+
115+
// Record history snapshot
116+
try {
117+
await pool.query(
118+
`INSERT INTO server_history
119+
(server_ip, server_port, game, status, map, player_count, maxplayers, version)
120+
VALUES (?, ?, ?, 1, ?, ?, ?, ?)`,
121+
[ip, port, game, sanitizedMap, playerCount, maxPlayers, srv.version || ""],
122+
);
123+
} catch (histErr) {
124+
logger.error("Failed to record server history from extension", { error: histErr.message });
125+
}
126+
127+
// Track player sessions
128+
const serverKey = `${ip}:${port}`;
129+
const previousPlayers = previousServerStates.get(serverKey) || new Set();
130+
const currentPlayerIds = new Set();
131+
132+
for (const player of extensionPlayers) {
133+
if (!player.steamid || !player.in_game) continue;
134+
currentPlayerIds.add(player.steamid);
135+
136+
if (!previousPlayers.has(player.steamid)) {
137+
try {
138+
const cleanName = sanitizePlayerName(player.name) || "Unknown";
139+
await pool.query(
140+
`INSERT INTO player_sessions (steamid, name, server_ip, server_port, joined_at)
141+
VALUES (?, ?, ?, ?, NOW())`,
142+
[player.steamid, cleanName, ip, port],
143+
);
144+
} catch (sessErr) {
145+
logger.error("Failed to track player join", { error: sessErr.message });
146+
}
147+
}
148+
}
149+
150+
// Players who left
151+
for (const playerId of previousPlayers) {
152+
if (!currentPlayerIds.has(playerId)) {
153+
try {
154+
await pool.query(
155+
`UPDATE player_sessions
156+
SET left_at = NOW(), duration = TIMESTAMPDIFF(SECOND, joined_at, NOW())
157+
WHERE steamid = ? AND server_ip = ? AND server_port = ? AND left_at IS NULL`,
158+
[playerId, ip, port],
159+
);
160+
} catch (sessErr) {
161+
logger.error("Failed to track player leave", { error: sessErr.message });
162+
}
163+
}
164+
}
165+
previousServerStates.set(serverKey, currentPlayerIds);
166+
167+
// Track map changes
168+
const currentMap = currentMapStates.get(serverKey);
169+
if (currentMap && currentMap.name !== sanitizedMap) {
170+
try {
171+
await pool.query(
172+
`UPDATE map_history SET ended_at = NOW(), duration = TIMESTAMPDIFF(SECOND, started_at, NOW())
173+
WHERE server_ip = ? AND server_port = ? AND ended_at IS NULL`,
174+
[ip, port],
175+
);
176+
await pool.query(
177+
`INSERT INTO map_history (server_ip, server_port, map_name, started_at, player_count_avg, player_count_peak)
178+
VALUES (?, ?, ?, NOW(), ?, ?)`,
179+
[ip, port, sanitizedMap, playerCount, playerCount],
180+
);
181+
} catch (mapErr) {
182+
logger.error("Failed to track map change", { error: mapErr.message });
183+
}
184+
} else if (!currentMap && sanitizedMap) {
185+
try {
186+
await pool.query(
187+
`INSERT INTO map_history (server_ip, server_port, map_name, started_at, player_count_avg, player_count_peak)
188+
VALUES (?, ?, ?, NOW(), ?, ?)`,
189+
[ip, port, sanitizedMap, playerCount, playerCount],
190+
);
191+
} catch (mapErr) {
192+
logger.error("Failed to init map tracking", { error: mapErr.message });
193+
}
194+
} else if (currentMap && currentMap.name === sanitizedMap) {
195+
try {
196+
await pool.query(
197+
`UPDATE map_history
198+
SET player_count_peak = GREATEST(player_count_peak, ?), player_count_avg = (player_count_avg + ?) / 2
199+
WHERE server_ip = ? AND server_port = ? AND ended_at IS NULL`,
200+
[playerCount, playerCount, ip, port],
201+
);
202+
} catch (mapErr) {
203+
logger.error("Failed to update map player counts", { error: mapErr.message });
204+
}
205+
}
206+
currentMapStates.set(serverKey, { name: sanitizedMap, playerCount });
207+
208+
// Update individual player stats
209+
// Use a reasonable increment, extension reports every ~10s, but we don't
210+
// want to assume. use the actual interval between reports for this server.
211+
const PLAYTIME_INCREMENT = 10; // seconds (matches extension default interval)
212+
213+
for (const player of extensionPlayers) {
214+
if (!player.steamid || !player.in_game) continue;
215+
216+
const cleanName = sanitizePlayerName(player.name) || "Unknown";
217+
218+
// Upsert player record
219+
await pool.query(
220+
`INSERT INTO players (steamid, latest_name, latest_ip, game, playtime, server_ip, server_port, last_seen)
221+
VALUES (?, ?, NULL, ?, ?, ?, ?, NOW())
222+
ON DUPLICATE KEY UPDATE
223+
latest_name=VALUES(latest_name),
224+
playtime=playtime+?,
225+
server_ip=VALUES(server_ip),
226+
server_port=VALUES(server_port),
227+
last_seen=NOW()`,
228+
[player.steamid, cleanName, game, PLAYTIME_INCREMENT, ip, port, PLAYTIME_INCREMENT],
229+
);
230+
231+
// Store player IP privately (not in players table)
232+
if (player.ip) {
233+
try {
234+
await pool.query(
235+
`INSERT INTO player_ips (steamid, ip, first_seen, last_seen)
236+
VALUES (?, ?, NOW(), NOW())
237+
ON DUPLICATE KEY UPDATE last_seen = NOW()`,
238+
[player.steamid, player.ip],
239+
);
240+
} catch (ipErr) {
241+
logger.error("Failed to store player IP", { error: ipErr.message });
242+
}
243+
}
244+
245+
emitPlayerUpdate({
246+
steamid: player.steamid,
247+
name: cleanName,
248+
server: serverKey,
249+
});
250+
}
251+
252+
// Track map playtime
253+
if (sanitizedMap) {
254+
await pool.query(
255+
`INSERT INTO maps (name, game, playtime, server_ip, server_port, last_played)
256+
VALUES (?, ?, ?, ?, ?, NOW())
257+
ON DUPLICATE KEY UPDATE
258+
playtime=playtime+?,
259+
server_ip=VALUES(server_ip),
260+
server_port=VALUES(server_port),
261+
last_played=NOW()`,
262+
[sanitizedMap, game, PLAYTIME_INCREMENT, ip, port, PLAYTIME_INCREMENT],
263+
);
264+
}
265+
266+
// Emit WebSocket events
267+
const serverData = {
268+
ip,
269+
port,
270+
game,
271+
status: 1,
272+
map: sanitizedMap,
273+
players: playerCount,
274+
version: srv.sm_version || "",
275+
};
276+
emitServerUpdate(serverData);
277+
278+
if (!previousServer || previousServer.status === 0) {
279+
emitServerStatusChange({ ...serverData, statusChange: "online" });
280+
}
281+
if (previousServer && previousServer.map !== sanitizedMap) {
282+
emitMapUpdate({ server: serverKey, oldMap: previousServer.map, newMap: sanitizedMap });
283+
}
284+
285+
// Invalidate caches
286+
await deleteCache("cache:servers:*");
287+
await deleteCache("cache:players:*");
288+
await deleteCache("cache:maps:*");
289+
290+
res.json({ ok: true });
291+
} catch (e) {
292+
logger.error(`Extension status ingest failed: ${e.message}`);
293+
res.status(500).json({ error: "Failed to process server status" });
294+
}
295+
});
296+
297+
module.exports = router;

src/app.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const swaggerSpec = require("./config/swagger");
66
const compression = require("compression");
77
const app = express();
88

9+
const serverStatusRouter = require("./api/serverStatus");
910
const serversRouter = require("./api/servers");
1011
const playersRouter = require("./api/players");
1112
const mapsRouter = require("./api/maps");
@@ -100,6 +101,7 @@ app.use(
100101
}),
101102
);
102103

104+
app.use("/servers/status", adminAuth, serverStatusRouter);
103105
app.use("/servers", serversRouter);
104106
app.use("/players", playersRouter);
105107
app.use("/maps", mapsRouter);

src/services/liveServers.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* Live Server Tracker
3+
*
4+
* Tracks which servers are actively reporting via the extension.
5+
* When a server has recent live data, the updater skips external
6+
* queries (Steam Master, GameDig, RCON) for that server.
7+
*/
8+
9+
const logger = require("../utils/logger");
10+
11+
// Map of "ip:port" -> { lastReport: timestamp }
12+
const liveServers = new Map();
13+
14+
// Consider a server "live" if it reported within this threshold
15+
const STALENESS_THRESHOLD_MS = 120_000; // 2 minutes
16+
17+
/**
18+
* Mark a server as having received live extension data
19+
* @param {string} ip
20+
* @param {number} port
21+
*/
22+
function markServerLive(ip, port) {
23+
const key = `${ip}:${port}`;
24+
liveServers.set(key, { lastReport: Date.now() });
25+
}
26+
27+
/**
28+
* Check if a server has recent live data from the extension
29+
* @param {string} ip
30+
* @param {number} port
31+
* @returns {boolean}
32+
*/
33+
function isServerLive(ip, port) {
34+
const key = `${ip}:${port}`;
35+
const entry = liveServers.get(key);
36+
if (!entry) return false;
37+
38+
const age = Date.now() - entry.lastReport;
39+
if (age > STALENESS_THRESHOLD_MS) {
40+
liveServers.delete(key);
41+
logger.debug(`Server ${key} live data stale (${Math.round(age / 1000)}s), will resume polling`);
42+
return false;
43+
}
44+
45+
return true;
46+
}
47+
48+
/**
49+
* Get count of currently live servers
50+
* @returns {number}
51+
*/
52+
function getLiveServerCount() {
53+
// Clean stale entries
54+
const now = Date.now();
55+
for (const [key, entry] of liveServers) {
56+
if (now - entry.lastReport > STALENESS_THRESHOLD_MS) {
57+
liveServers.delete(key);
58+
}
59+
}
60+
return liveServers.size;
61+
}
62+
63+
module.exports = { markServerLive, isServerLive, getLiveServerCount };

0 commit comments

Comments
 (0)