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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user