Replace IW4MAdmin with direct RCON for player counts
IW4M kept hitting a network wall (container -> host LAN IP -> IW4M loopback bind) that we couldn't cleanly punch through, and the two-minute !rt token added enough operational friction that the pause-all-deployments gate never got a fair test. RCON gives us the same fact — how many players are on a server right now — direct from each game server over UDP, with no session, no token, no proxy. - Add integrations/rcon.ts: dgram UDP client. rconGetPlayerCount() and rconGetStatus() parse both `status` (print + scoreboard) and `statusResponse` (info line + rows) dialects. Timeout via RCON_TIMEOUT_MS. - Add komodoResolveGamePort() + extractUdpPort(). Reads Komodo's read API for a server, walks both docker-compose-style port objects and docker-run-style "host:container/udp" strings, caches into servers.game_port on first success. - Replace pipelineReady() guard in sweep with a simple integrations-configured check. Same held/failed/manual outcomes, but keyed on RCON+Komodo instead of IW4M+auth-state. - Worker's status refresh fans out per-server over RCON at concurrency=4 so a 15-server fleet doesn't spike UDP. - Schema: drop iw4m_match/iw4m_value, add game_port + rcon_host. - Delete every auth artifact: TokenModal, auth store, auth-state module, /api/auth/token, /api/events/auth, iw4m.test.ts. Layout no longer mounts the modal, pipeline store no longer tracks a frozen state. - README + .env.example + docker-compose.yml + komodo/stack.toml swap IW4M_* env for RCON_PASSWORD / RCON_HOST / RCON_TIMEOUT_MS. Same host.docker.internal:host-gateway extra_hosts entry, repurposed for RCON reach. - Tests: add rcon.test.ts and komodo.test.ts, update sweep.test.ts to drop the auth-freeze case. 23/23 passing.
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import { createSocket } from 'node:dgram';
|
||||
import type { Socket } from 'node:dgram';
|
||||
import { config, rconConfigured } from '../config.js';
|
||||
import { getServer, setServerField } from '../fleet/servers.js';
|
||||
import { komodoResolveGamePort } from './komodo.js';
|
||||
|
||||
// Plutonium/CoD RCON is UDP with a 4-byte 0xff prefix. Requests look like
|
||||
// \xff\xff\xff\xffrcon <password> <command>
|
||||
// Responses look like
|
||||
// \xff\xff\xff\xffprint\n... (older builds)
|
||||
// \xff\xff\xff\xffstatusResponse\n<info-line>\n<player rows> (getstatus)
|
||||
// `status` returns a `print` response with a human-readable header table
|
||||
// followed by one row per connected player. We parse either — first try
|
||||
// print (from `status`) which gives us a clean player row count; fall back
|
||||
// to statusResponse if the server dialect prefers that.
|
||||
|
||||
const PREFIX = Buffer.from([0xff, 0xff, 0xff, 0xff]);
|
||||
|
||||
async function sendRcon(host: string, port: number, command: string): Promise<Buffer | null> {
|
||||
if (!rconConfigured()) return null;
|
||||
const payload = Buffer.concat([
|
||||
PREFIX,
|
||||
Buffer.from(`rcon ${config.rcon.password} ${command}\n`, 'utf8')
|
||||
]);
|
||||
|
||||
return new Promise<Buffer | null>((resolve) => {
|
||||
const sock: Socket = createSocket('udp4');
|
||||
let settled = false;
|
||||
|
||||
const finish = (val: Buffer | null): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
sock.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
resolve(val);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => finish(null), config.rcon.timeoutMs);
|
||||
|
||||
sock.on('message', (msg) => {
|
||||
clearTimeout(timer);
|
||||
finish(msg);
|
||||
});
|
||||
sock.on('error', () => {
|
||||
clearTimeout(timer);
|
||||
finish(null);
|
||||
});
|
||||
|
||||
sock.send(payload, 0, payload.length, port, host, (err) => {
|
||||
if (err) {
|
||||
clearTimeout(timer);
|
||||
finish(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the RCON endpoint (host + port) for a server, filling
|
||||
* `servers.game_port` from Komodo on first miss. Returns null when we
|
||||
* still can't determine a port after asking Komodo.
|
||||
*/
|
||||
async function endpointFor(server: string): Promise<{ host: string; port: number } | null> {
|
||||
const row = getServer(server);
|
||||
if (!row) return null;
|
||||
let port = row.game_port;
|
||||
if (!port) {
|
||||
port = await komodoResolveGamePort(server);
|
||||
if (port) setServerField(server, 'game_port', port);
|
||||
}
|
||||
if (!port) return null;
|
||||
return { host: row.rcon_host ?? config.rcon.host, port };
|
||||
}
|
||||
|
||||
function stripPrefix(buf: Buffer): string | null {
|
||||
if (buf.length < PREFIX.length) return null;
|
||||
for (let i = 0; i < PREFIX.length; i++) if (buf[i] !== PREFIX[i]) return null;
|
||||
return buf.subarray(PREFIX.length).toString('utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of player rows in a `status` response. The `print`
|
||||
* response body looks like:
|
||||
*
|
||||
* print
|
||||
* map: mp_kino
|
||||
* num score ping guid name
|
||||
* --- ----- ---- -------- ----
|
||||
* 0 12 45 abc123...def joe
|
||||
* 1 0 72 xyz789...abc jill
|
||||
*
|
||||
* We look for the `--- ---` separator and count non-blank lines after it.
|
||||
* Falls through to a statusResponse parse if the payload uses that dialect.
|
||||
*/
|
||||
export function parsePlayerCount(text: string): number | null {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const sep = lines.findIndex((l) => /^-+\s+-+/.test(l.trim()));
|
||||
if (sep !== -1) {
|
||||
let n = 0;
|
||||
for (let i = sep + 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
// Player rows begin with the client num (digits) then whitespace.
|
||||
if (/^\d+\s+/.test(line)) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
// statusResponse dialect: info line starts with '\', players start on
|
||||
// the line after. Same idea — count non-blank lines past the info.
|
||||
const info = lines.findIndex((l) => l.startsWith('\\'));
|
||||
if (info === -1) return null;
|
||||
let n = 0;
|
||||
for (let i = info + 1; i < lines.length; i++) {
|
||||
if (lines[i].trim()) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `\key\value\key\value…` from a statusResponse info line.
|
||||
*/
|
||||
export function parseInfoLine(text: string): Record<string, string> {
|
||||
const info = text.split(/\r?\n/).find((l) => l.startsWith('\\'));
|
||||
if (!info) return {};
|
||||
const out: Record<string, string> = {};
|
||||
const parts = info.split('\\').filter(Boolean);
|
||||
for (let i = 0; i < parts.length - 1; i += 2) out[parts[i]] = parts[i + 1];
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function rconGetPlayerCount(server: string): Promise<number | null> {
|
||||
const ep = await endpointFor(server);
|
||||
if (!ep) return null;
|
||||
const buf = await sendRcon(ep.host, ep.port, 'status');
|
||||
if (!buf) return null;
|
||||
const text = stripPrefix(buf);
|
||||
if (!text) return null;
|
||||
return parsePlayerCount(text);
|
||||
}
|
||||
|
||||
export interface RconStatus {
|
||||
players: number;
|
||||
map: string | null;
|
||||
gametype: string | null;
|
||||
}
|
||||
|
||||
export async function rconGetStatus(server: string): Promise<RconStatus | null> {
|
||||
const ep = await endpointFor(server);
|
||||
if (!ep) return null;
|
||||
// `getstatus` is anonymous on most CoD builds but Plutonium requires the
|
||||
// authenticated rcon route for reliable results, so we ask via rcon.
|
||||
const buf = await sendRcon(ep.host, ep.port, 'getstatus');
|
||||
if (!buf) return null;
|
||||
const text = stripPrefix(buf);
|
||||
if (!text) return null;
|
||||
const info = parseInfoLine(text);
|
||||
const players = parsePlayerCount(text);
|
||||
if (players === null) return null;
|
||||
return {
|
||||
players,
|
||||
map: info.mapname ?? info.sv_mapname ?? null,
|
||||
gametype: info.g_gametype ?? null
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user