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
+29 -30
View File
@@ -1,11 +1,13 @@
import { sweep } from './sweep.js';
import { emit } from './bus.js';
import { fetchStatus } from '../integrations/iw4m.js';
import { rconGetStatus } from '../integrations/rcon.js';
import { listServers, seedServerRegistry } from '../fleet/servers.js';
import { getAuthState } from '../integrations/auth-state.js';
const TICK_MS = 15_000;
const STATUS_REFRESH_MS = 30_000;
// Cap concurrent UDP RCON calls so a 15-server fleet doesn't blast the
// host all at once.
const STATUS_CONCURRENCY = 4;
let started = false;
let tickTimer: NodeJS.Timeout | null = null;
@@ -19,44 +21,41 @@ async function tick(): Promise<void> {
}
}
async function pushServerStatus(): Promise<void> {
const st = await fetchStatus();
if (!st.ok) return;
const rows = listServers();
const byName = new Map(rows.map((r) => [r.name, r]));
// For the dashboard: emit a lightweight snapshot. Actual per-server
// resolution is handled by playersOn() during a sweep, which caches
// iw4m_match/value into the servers table.
emit({
type: 'server-status',
entries: st.entries.map((e) => ({
server: e.name ?? String(e.port ?? ''),
players: e.currentPlayers ?? null,
name: e.name
}))
});
// Also fold current auth ttl into the auth channel so subscribers get a
// heartbeat and a countdown without waiting for a transition.
const auth = getAuthState();
emit({
type: 'auth-state',
state: auth,
msRemaining: auth.kind === 'ready' ? Math.max(0, auth.expiresAt - Date.now()) : undefined
});
void byName; // reserved: future join between /api/status and server registry
async function fanoutStatus(): Promise<void> {
const servers = listServers();
if (servers.length === 0) return;
const entries: Array<{ server: string; players: number | null; name?: string }> = new Array(servers.length);
let cursor = 0;
async function pump(): Promise<void> {
while (true) {
const i = cursor++;
if (i >= servers.length) return;
const srv = servers[i];
const status = await rconGetStatus(srv.name).catch(() => null);
entries[i] = {
server: srv.name,
players: status?.players ?? null,
name: status?.map ?? undefined
};
}
}
await Promise.all(Array.from({ length: STATUS_CONCURRENCY }, pump));
emit({ type: 'server-status', entries });
}
export function startWorker(): void {
if (started) return;
started = true;
// Seed the servers table from the fs so first-boot has rows to reference.
seedServerRegistry().catch((err) => console.error('[worker] seed error:', err));
void tick();
void pushServerStatus();
void fanoutStatus();
tickTimer = setInterval(tick, TICK_MS);
statusTimer = setInterval(pushServerStatus, STATUS_REFRESH_MS);
statusTimer = setInterval(fanoutStatus, STATUS_REFRESH_MS);
console.log('[worker] started — sweep every', TICK_MS, 'ms, status every', STATUS_REFRESH_MS, 'ms');
}