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:
2026-09-12 01:33:06 -04:00
parent f118c179cd
commit 854f5532a9
27 changed files with 530 additions and 730 deletions
+167
View File
@@ -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
};
}