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
+102
View File
@@ -79,3 +79,105 @@ export async function komodoRestart(serverName: string): Promise<KomodoRestartRe
}
return { ok: true };
}
/**
* Query Komodo's read API for a server's UDP game port. Called lazily by
* the RCON client and cached into `servers.game_port` on success.
*
* Komodo returns full deployment/container state; port mappings live under
* `state.container.ports` on deployments and `state.ports` on stacks/raw
* containers. Shape varies by Komodo version, so we walk a few known
* candidate paths and pick the first UDP entry we can parse.
*/
export async function komodoResolveGamePort(serverName: string): Promise<number | null> {
if (!komodoConfigured()) return null;
const row = getServer(serverName);
if (!row?.komodo_name) return null;
const type = row.komodo_type ?? 'deployment';
let path: string;
let body: Record<string, string>;
switch (type) {
case 'stack':
path = 'read/GetStack';
body = { stack: row.komodo_name };
break;
case 'container':
if (!row.komodo_server) return null;
path = 'read/InspectContainer';
body = { server: row.komodo_server, container: row.komodo_name };
break;
case 'deployment':
default:
path = 'read/GetDeployment';
body = { deployment: row.komodo_name };
break;
}
let res: Response;
try {
res = await komodoCall(path, body);
} catch {
return null;
}
if (!res.ok) return null;
let json: unknown;
try {
json = await res.json();
} catch {
return null;
}
return extractUdpPort(json);
}
/**
* Walks a Komodo response looking for a UDP port. Handles the two shapes
* we've seen in the wild:
*
* docker-compose-style: [{ protocol: "udp", published: 4976, target: 28960 }, ...]
* docker-run-style: ["4976:28960/udp", "3074:3074/tcp"]
*
* Returns the first published port whose protocol is UDP.
*/
export function extractUdpPort(payload: unknown): number | null {
const candidates: unknown[] = [];
const seen = new Set<unknown>();
const walk = (v: unknown): void => {
if (v === null || typeof v !== 'object' || seen.has(v)) return;
seen.add(v);
if (Array.isArray(v)) {
for (const item of v) walk(item);
candidates.push(v);
} else {
for (const val of Object.values(v as Record<string, unknown>)) walk(val);
}
};
walk(payload);
// Object-form entries: { protocol, published, target }
for (const arr of candidates) {
for (const entry of arr as unknown[]) {
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
const e = entry as Record<string, unknown>;
const proto = String(e.protocol ?? e.Protocol ?? '').toLowerCase();
if (proto !== 'udp') continue;
const raw = e.published ?? e.PublishedPort ?? e.public_port ?? e.HostPort ?? e.host_port;
const num = typeof raw === 'string' ? Number.parseInt(raw, 10) : Number(raw);
if (Number.isFinite(num) && num > 0) return num;
}
}
}
// String-form entries: "4976:28960/udp"
for (const arr of candidates) {
for (const entry of arr as unknown[]) {
if (typeof entry !== 'string') continue;
const m = /^(\d+):\d+\/udp$/i.exec(entry);
if (m) return Number.parseInt(m[1], 10);
}
}
return null;
}