From 854f5532a939639ad58d52d7e2c197feb8bd78a0 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 12 Sep 2026 01:33:06 -0400 Subject: [PATCH] Replace IW4MAdmin with direct RCON for player counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 12 +- README.md | 57 ++++-- docker-compose.yml | 10 +- komodo/stack.toml | 11 +- src/lib/server/config.ts | 16 +- src/lib/server/db/schema.sql | 4 +- src/lib/server/fleet/servers.ts | 8 +- src/lib/server/integrations/auth-state.ts | 54 ------ src/lib/server/integrations/iw4m.ts | 176 ------------------ src/lib/server/integrations/komodo.ts | 102 ++++++++++ src/lib/server/integrations/rcon.ts | 167 +++++++++++++++++ src/lib/server/pipeline/bus.ts | 7 +- src/lib/server/pipeline/sweep.ts | 58 +++--- src/lib/server/pipeline/worker.ts | 59 +++--- src/lib/ui/components/PipelineTimeline.svelte | 6 +- src/lib/ui/components/TokenModal.svelte | 160 ---------------- src/lib/ui/stores/auth.ts | 51 ----- src/lib/ui/stores/pipeline.ts | 8 - src/routes/+layout.svelte | 5 - src/routes/+page.svelte | 4 +- src/routes/api/auth/token/+server.ts | 30 --- src/routes/api/events/auth/+server.ts | 47 ----- src/routes/servers/[name]/+page.svelte | 12 +- tests/unit/iw4m.test.ts | 72 ------- tests/unit/komodo.test.ts | 48 +++++ tests/unit/rcon.test.ts | 59 ++++++ tests/unit/sweep.test.ts | 17 +- 27 files changed, 530 insertions(+), 730 deletions(-) delete mode 100644 src/lib/server/integrations/auth-state.ts delete mode 100644 src/lib/server/integrations/iw4m.ts create mode 100644 src/lib/server/integrations/rcon.ts delete mode 100644 src/lib/ui/components/TokenModal.svelte delete mode 100644 src/lib/ui/stores/auth.ts delete mode 100644 src/routes/api/auth/token/+server.ts delete mode 100644 src/routes/api/events/auth/+server.ts delete mode 100644 tests/unit/iw4m.test.ts create mode 100644 tests/unit/komodo.test.ts create mode 100644 tests/unit/rcon.test.ts diff --git a/.env.example b/.env.example index 9dc9b90..9bdb2a9 100644 --- a/.env.example +++ b/.env.example @@ -14,10 +14,12 @@ KOMODO_ADDRESS=https://komodo.example.internal KOMODO_API_KEY= KOMODO_API_SECRET= -# IW4MAdmin -IW4M_ADDRESS=http://iw4m.example.internal -IW4M_CLIENT_ID= -# no password here — !rt token is provided at runtime through the modal +# RCON — shared across the fleet (every server uses the same rcon_password). +# Host defaults to host.docker.internal so the container can reach game +# servers on the same Docker host; override if RCON lives elsewhere. +RCON_PASSWORD= +RCON_HOST=host.docker.internal +RCON_TIMEOUT_MS=3000 -# HTTP +# HTTP (Komodo API) HTTP_TIMEOUT_MS=10000 diff --git a/README.md b/README.md index 4b24949..ccda30f 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,17 @@ Web app that manages a fleet of T5 Plutonium servers running under Komodo. Successor to `push_scripts.sh` — same job (push shared GSC scripts, run beta -tests, restart empty servers, track deployments) with a live UI, real audit -diffs, and a hard pause when the IW4MAdmin `!rt` token goes stale. +tests, restart empty servers, track deployments) with a live UI and real +audit diffs. ## Stack - SvelteKit (adapter-node) + TypeScript - SQLite via better-sqlite3 (replaces `script_metadata.json` as the source of truth) -- Server-sent events for live pipeline / auth-state / server-status streams +- Server-sent events for live pipeline / server-status streams +- RCON (UDP) direct-to-game-server for player counts +- Komodo API for restarts and per-server game-port discovery - Runs as a single Komodo-managed container on the same host as the game servers, with `/root/zombies` bind-mounted in @@ -18,15 +20,28 @@ diffs, and a hard pause when the IW4MAdmin `!rt` token goes stale. - `src/lib/server/db/` — schema, migrations, better-sqlite3 handle - `src/lib/server/fleet/` — file walking, push/beta engines -- `src/lib/server/integrations/` — Komodo, IW4MAdmin, `!rt` token state +- `src/lib/server/integrations/` — Komodo, RCON - `src/lib/server/pipeline/` — sweep loop, event bus, worker singleton - `src/routes/api/` — REST + SSE endpoints - `src/routes/` — UI pages (dashboard, deployments, betas, per-server) -- `docker/` — Dockerfile, entrypoint, docker-compose +- `Dockerfile`, `docker-compose.yml`, `entrypoint.sh` — container build - `komodo/` — Komodo stack definition - `scripts/` — legacy JSON importer, fixture seeder - `tests/` — vitest unit tests +## How restarts stay safe + +Every push (or beta open) creates a `deployment` row and marks each affected +server `pending`. A background sweep runs every 15s and: + +1. Asks each server over RCON: how many players are on right now? +2. If 0 → tell Komodo to restart the server → row goes `done`. +3. If >0 → hold the row `pending` with the player count; try again next tick. +4. If RCON unreachable → hold `pending` with `error='rcon unreachable'`. + +No token, no session, no auth freeze. If Komodo or RCON aren't configured, +the sweep marks pending rows `manual` so nothing hangs forever. + ## Local dev Windows or macOS — no `/root/zombies` on disk, so seed a fixture tree first. @@ -44,10 +59,9 @@ npm run dev # open http://localhost:3000 ``` -The `!rt` modal will block deployments until you paste a valid token. In -local dev you can bypass it for exploration by leaving `IW4M_ADDRESS` unset -and calling `sweep()` in a test that force-transitions the auth state (see -`tests/unit/sweep.test.ts`). +Without RCON_PASSWORD / KOMODO_* set, the pipeline marks pending rows +`manual` after a sweep — the UI still works, you just don't get real +restarts. Enough to develop the push/beta flow. ## Tests @@ -56,8 +70,8 @@ npm test ``` Covers push (add/update/skip), beta lifecycle (open → rollback, open → -promote, conflict blocks), sweep (frozen / manual branches), and IW4M -login/status parsing (fetch mocked). +promote, conflict blocks), sweep (integrations-off manual branch), and +RCON parsing (fake UDP socket). ## Deploy to the Komodo host @@ -67,16 +81,23 @@ login/status parsing (fetch mocked). docker compose build ``` -2. Import the shape of the stack into Komodo using `komodo/stack.toml` (or - copy the compose file into a Komodo stack). +2. Import `komodo/stack.toml` into Komodo (or point Komodo at the + `docker-compose.yml` at repo root). -3. First boot triggers the legacy import: if +3. Set the env vars in Komodo: + + ``` + KOMODO_ADDRESS, KOMODO_API_KEY, KOMODO_API_SECRET + RCON_PASSWORD # single shared password across every T5 server + RCON_HOST # optional; defaults to host.docker.internal + ``` + +4. First boot triggers the legacy import: if `/root/zombies/script_metadata.json` exists and `/data/cherry.db` is - empty, `docker/entrypoint.sh` runs `scripts/import-legacy.ts` before - starting the server. The source JSON gets renamed to `.migrated`. + empty, `entrypoint.sh` runs `scripts/import-legacy.ts` before starting + the server. The source JSON gets renamed to `.migrated`. -4. Load the app on the LAN or over tailscale (no web-app auth by design). - Paste an `!rt` token to unfreeze the pipeline. +5. Load the app on the LAN or over tailscale (no web-app auth by design). ## What still lives in the shell script diff --git a/docker-compose.yml b/docker-compose.yml index 13327f1..dfacccf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,11 @@ services: restart: unless-stopped ports: - "3000:3000" + # Lets RCON_HOST=host.docker.internal reach game servers running on the + # same Komodo host. Docker Engine 20.10+ resolves this alias when the + # extra_hosts entry is present. + extra_hosts: + - "host.docker.internal:host-gateway" volumes: # in prod (Komodo host), the game server tree is at /root/zombies - /root/zombies:/root/zombies:rw @@ -21,5 +26,6 @@ services: KOMODO_ADDRESS: ${KOMODO_ADDRESS} KOMODO_API_KEY: ${KOMODO_API_KEY} KOMODO_API_SECRET: ${KOMODO_API_SECRET} - IW4M_ADDRESS: ${IW4M_ADDRESS} - IW4M_CLIENT_ID: ${IW4M_CLIENT_ID} + RCON_PASSWORD: ${RCON_PASSWORD} + RCON_HOST: ${RCON_HOST:-host.docker.internal} + RCON_TIMEOUT_MS: ${RCON_TIMEOUT_MS:-3000} diff --git a/komodo/stack.toml b/komodo/stack.toml index 3eec290..e0855d2 100644 --- a/komodo/stack.toml +++ b/komodo/stack.toml @@ -16,6 +16,10 @@ volumes = [ "/root/zombies/.electric-cherry:/data:rw" ] +# Lets RCON_HOST=host.docker.internal reach game servers running on the +# same Komodo host without knowing each container's internal IP. +extra_hosts = ["host.docker.internal:host-gateway"] + [stack.services.web.env] SHARED_SCRIPTS = "/root/zombies/shared/scripts" SERVERS_ROOT = "/root/zombies/t5" @@ -28,5 +32,8 @@ DEPLOY_KEEP_DAYS = "7" KOMODO_ADDRESS = "https://komodo.example.internal" KOMODO_API_KEY = "" KOMODO_API_SECRET = "" -IW4M_ADDRESS = "http://iw4m.example.internal" -IW4M_CLIENT_ID = "" + +# RCON — shared password across every T5 Plutonium server in the fleet. +RCON_PASSWORD = "" +RCON_HOST = "host.docker.internal" +RCON_TIMEOUT_MS = "3000" diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts index 687ec04..3165839 100644 --- a/src/lib/server/config.ts +++ b/src/lib/server/config.ts @@ -26,9 +26,15 @@ export const config = { apiKey: env.KOMODO_API_KEY ?? '', apiSecret: env.KOMODO_API_SECRET ?? '' }, - iw4m: { - address: env.IW4M_ADDRESS ?? '', - clientId: env.IW4M_CLIENT_ID ?? '' + rcon: { + // Host every RCON packet is sent to. Defaults to the Docker + // host-gateway alias so the electric-cherry container can reach + // Plutonium servers running on the same host without knowing each + // container's internal IP. + host: env.RCON_HOST ?? 'host.docker.internal', + // Shared across the fleet — every server uses the same rcon_password. + password: env.RCON_PASSWORD ?? '', + timeoutMs: num('RCON_TIMEOUT_MS', 3_000) } } as const; @@ -36,8 +42,8 @@ export function komodoConfigured(): boolean { return !!(config.komodo.address && config.komodo.apiKey && config.komodo.apiSecret); } -export function iw4mConfigured(): boolean { - return !!(config.iw4m.address && config.iw4m.clientId); +export function rconConfigured(): boolean { + return !!config.rcon.password; } export const betaSrc = () => `${config.sharedScripts}/betas`; diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index a3b05cb..422ae9e 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -9,8 +9,8 @@ CREATE TABLE IF NOT EXISTS servers ( komodo_type TEXT NOT NULL DEFAULT 'deployment', -- deployment|stack|container komodo_name TEXT NOT NULL, komodo_server TEXT, - iw4m_match TEXT, -- port|id|name - iw4m_value TEXT, + game_port INTEGER, -- UDP RCON port; lazy-filled from Komodo + rcon_host TEXT, -- optional per-server override of RCON_HOST last_players INTEGER, last_checked_at TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) diff --git a/src/lib/server/fleet/servers.ts b/src/lib/server/fleet/servers.ts index adf7a67..51a1449 100644 --- a/src/lib/server/fleet/servers.ts +++ b/src/lib/server/fleet/servers.ts @@ -67,8 +67,8 @@ export interface ServerRow { komodo_type: 'deployment' | 'stack' | 'container'; komodo_name: string; komodo_server: string | null; - iw4m_match: 'port' | 'id' | 'name' | null; - iw4m_value: string | null; + game_port: number | null; + rcon_host: string | null; last_players: number | null; last_checked_at: string | null; created_at: string; @@ -113,8 +113,8 @@ export function setServerField( 'komodo_type', 'komodo_name', 'komodo_server', - 'iw4m_match', - 'iw4m_value', + 'game_port', + 'rcon_host', 'last_players', 'last_checked_at' ]; diff --git a/src/lib/server/integrations/auth-state.ts b/src/lib/server/integrations/auth-state.ts deleted file mode 100644 index 195a14d..0000000 --- a/src/lib/server/integrations/auth-state.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { emit } from '../pipeline/bus.js'; - -export type AuthState = - | { kind: 'ready'; token: string; cookie: string; expiresAt: number } - | { kind: 'stale'; reason: 'expired' | 'rejected' | 'unreachable' } - | { kind: 'missing' }; - -// Two minutes minus 5s slack — the !rt token really is single-use and dies -// fast. Better to preemptively mark stale than to fire a call with a dead -// session cookie mid-sweep. -export const TOKEN_TTL_MS = 115_000; -// How long before expiry the UI should re-prompt so the user has time to -// generate a new one in-game. -export const TOKEN_WARN_MS = 20_000; - -let state: AuthState = { kind: 'missing' }; - -export function getAuthState(): AuthState { - // Auto-transition from ready → stale when we cross the expiry boundary - if (state.kind === 'ready' && Date.now() >= state.expiresAt) { - state = { kind: 'stale', reason: 'expired' }; - emit({ type: 'auth-state', state }); - } - return state; -} - -export function setAuthState(next: AuthState): void { - state = next; - const payload: Parameters[0] = - next.kind === 'ready' - ? { type: 'auth-state', state: next, msRemaining: Math.max(0, next.expiresAt - Date.now()) } - : { type: 'auth-state', state: next }; - emit(payload); -} - -/** - * Web-app-side check used by the pipeline worker's tick loop. Returns true - * only when the token is ready AND has more than a couple seconds left. Any - * other state hard-freezes the pipeline. - */ -export function pipelineReady(): boolean { - const s = getAuthState(); - if (s.kind !== 'ready') return false; - return s.expiresAt - Date.now() > 3000; -} - -/** - * The UI shows a warning modal once we're within TOKEN_WARN_MS of expiry. - */ -export function tokenNearExpiry(): boolean { - const s = getAuthState(); - if (s.kind !== 'ready') return false; - return s.expiresAt - Date.now() <= TOKEN_WARN_MS; -} diff --git a/src/lib/server/integrations/iw4m.ts b/src/lib/server/integrations/iw4m.ts deleted file mode 100644 index 8cc7530..0000000 --- a/src/lib/server/integrations/iw4m.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { config, iw4mConfigured } from '../config.js'; -import { getServer, setServerField } from '../fleet/servers.js'; -import { getAuthState, setAuthState, TOKEN_TTL_MS } from './auth-state.js'; - -// Ports iw4m_login/fetch_status/players/autolink from push_scripts.sh:497-620. -// The one big difference: the script keeps a temp cookie-jar file, this holds -// the session cookie in memory (we're a single Node process, not a shell). - -export interface StatusEntry { - id?: number; - port?: number; - name?: string; - currentPlayers?: number; -} - -let statusCache: { entries: StatusEntry[]; at: number } | null = null; -const STATUS_TTL_MS = 5_000; - -function abortableFetch(url: string, init: RequestInit = {}): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), config.httpTimeoutMs); - return fetch(url, { ...init, signal: controller.signal }).finally(() => clearTimeout(timer)); -} - -/** - * Exchange the 4-char !rt token for a session cookie. Called by the - * `/api/auth/token` route when the user pastes the modal input. - */ -export async function loginWithToken( - token: string -): Promise<{ ok: true; cookie: string; expiresAt: number } | { ok: false; reason: 'rejected' | 'unreachable' | 'unconfigured'; message: string }> { - if (!iw4mConfigured()) return { ok: false, reason: 'unconfigured', message: 'iw4m not configured' }; - - const url = `${config.iw4m.address.replace(/\/+$/, '')}/api/client/${config.iw4m.clientId}/login`; - let res: Response; - try { - res = await abortableFetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password: token }) - }); - } catch (err) { - return { ok: false, reason: 'unreachable', message: (err as Error).message }; - } - - if (res.status === 401 || res.status === 403) return { ok: false, reason: 'rejected', message: 'iw4m rejected the token' }; - if (!res.ok) return { ok: false, reason: 'unreachable', message: `HTTP ${res.status}` }; - - // Grab every Set-Cookie the server handed us and re-send them intact. - // `getSetCookie` is Node 20+; on older Node use `res.headers.raw()['set-cookie']`. - let setCookies: string[] = []; - if (typeof (res.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie === 'function') { - setCookies = (res.headers as unknown as { getSetCookie: () => string[] }).getSetCookie(); - } else { - const single = res.headers.get('set-cookie'); - if (single) setCookies = [single]; - } - const cookie = setCookies - .map((c) => c.split(';')[0]) - .filter(Boolean) - .join('; '); - if (!cookie) return { ok: false, reason: 'rejected', message: 'iw4m returned no session cookie' }; - - return { ok: true, cookie, expiresAt: Date.now() + TOKEN_TTL_MS }; -} - -/** - * Fetch /api/status, using the current session cookie if we have one. Falls - * back to anonymous read — that's what the script does at line 542 — but the - * pipeline worker gates on the token being present anyway, so this is used - * mainly for the dashboard's non-blocking status refresh. - */ -export async function fetchStatus(): Promise<{ ok: true; entries: StatusEntry[] } | { ok: false; message: string }> { - if (!iw4mConfigured()) return { ok: false, message: 'iw4m not configured' }; - - if (statusCache && Date.now() - statusCache.at < STATUS_TTL_MS) { - return { ok: true, entries: statusCache.entries }; - } - - const url = `${config.iw4m.address.replace(/\/+$/, '')}/api/status`; - const s = getAuthState(); - const headers: Record = {}; - if (s.kind === 'ready') headers.cookie = s.cookie; - - let res: Response; - try { - res = await abortableFetch(url, { headers }); - } catch (err) { - return { ok: false, message: (err as Error).message }; - } - if (!res.ok) { - if ((res.status === 401 || res.status === 403) && s.kind === 'ready') { - // Cookie went sour — force a re-prompt. - setAuthState({ kind: 'stale', reason: 'rejected' }); - } - return { ok: false, message: `HTTP ${res.status}` }; - } - let json: unknown; - try { - json = await res.json(); - } catch (err) { - return { ok: false, message: `bad JSON: ${(err as Error).message}` }; - } - if (!Array.isArray(json)) return { ok: false, message: 'status was not an array' }; - - // Normalize keys — the script does the same case-insensitive dance - // (line 589) to work across IW4MAdmin versions. - const entries: StatusEntry[] = json.map((e) => { - const o = e as Record; - return { - id: (o.id ?? o.Id) as number | undefined, - port: (o.port ?? o.Port) as number | undefined, - name: (o.name ?? o.Name ?? '') as string, - currentPlayers: (o.currentPlayers ?? o.CurrentPlayers) as number | undefined - }; - }); - statusCache = { entries, at: Date.now() }; - return { ok: true, entries }; -} - -function normalizeName(s: string): string { - return s.toLowerCase().replace(/[^a-z0-9]/g, ''); -} - -/** - * How many players are on this server right now, or null if we can't tell. - * Ports `iw4m_players` in push_scripts.sh:579. - */ -export async function playersOn(server: string): Promise { - const row = getServer(server); - if (!row) return null; - const status = await fetchStatus(); - if (!status.ok) return null; - - let match = row.iw4m_match; - let value = row.iw4m_value; - if (!match || !value) { - const auto = autolink(server, status.entries); - if (!auto) return null; - setServerField(server, 'iw4m_match', auto.match); - setServerField(server, 'iw4m_value', auto.value); - match = auto.match; - value = auto.value; - } - - const hits = status.entries.filter((e) => { - if (match === 'port') return String(e.port ?? '') === value; - if (match === 'id') return String(e.id ?? '') === value; - return normalizeName(e.name ?? '') === normalizeName(value!); - }); - if (hits.length !== 1) return null; - const players = hits[0].currentPlayers; - return typeof players === 'number' ? players : null; -} - -/** - * First-run guess: pick the first non-generic token in the server folder - * name and look for a matching IW4M server by name. Ports `iw4m_autolink` - * in push_scripts.sh:605. - */ -function autolink(server: string, entries: StatusEntry[]): { match: 'port'; value: string } | null { - const skip = new Set(['t4', 't5', 't6', 'iw5', 'iw4']); - const parts = server - .replace(/[^a-zA-Z0-9]+/g, ' ') - .trim() - .split(' ') - .filter((p) => p && !skip.has(p.toLowerCase()) && !/^\d+$/.test(p)); - if (parts.length === 0) return null; - const token = parts[0].toLowerCase(); - - const hits = entries.filter((e) => normalizeName(e.name ?? '').includes(token)); - if (hits.length !== 1) return null; - const port = hits[0].port; - if (typeof port !== 'number') return null; - return { match: 'port', value: String(port) }; -} diff --git a/src/lib/server/integrations/komodo.ts b/src/lib/server/integrations/komodo.ts index 0431783..ad2d5ef 100644 --- a/src/lib/server/integrations/komodo.ts +++ b/src/lib/server/integrations/komodo.ts @@ -79,3 +79,105 @@ export async function komodoRestart(serverName: string): Promise { + 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; + + 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(); + 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)) 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; + 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; +} diff --git a/src/lib/server/integrations/rcon.ts b/src/lib/server/integrations/rcon.ts new file mode 100644 index 0000000..4822ee8 --- /dev/null +++ b/src/lib/server/integrations/rcon.ts @@ -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 +// Responses look like +// \xff\xff\xff\xffprint\n... (older builds) +// \xff\xff\xff\xffstatusResponse\n\n (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 { + if (!rconConfigured()) return null; + const payload = Buffer.concat([ + PREFIX, + Buffer.from(`rcon ${config.rcon.password} ${command}\n`, 'utf8') + ]); + + return new Promise((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 { + const info = text.split(/\r?\n/).find((l) => l.startsWith('\\')); + if (!info) return {}; + const out: Record = {}; + 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 { + 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 { + 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 + }; +} diff --git a/src/lib/server/pipeline/bus.ts b/src/lib/server/pipeline/bus.ts index 0d98258..4429cbf 100644 --- a/src/lib/server/pipeline/bus.ts +++ b/src/lib/server/pipeline/bus.ts @@ -1,8 +1,8 @@ import { EventEmitter } from 'node:events'; -// One in-process event bus, multiplexed by all three SSE endpoints. Kept -// separate from any specific subscriber so push/beta/sweep can emit without -// pulling the whole worker module in. +// One in-process event bus, multiplexed by the SSE endpoints. Kept separate +// from any specific subscriber so push/beta/sweep can emit without pulling +// the whole worker module in. export type PipelineEvent = | { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] } @@ -16,7 +16,6 @@ export type PipelineEvent = error: string | null; } | { type: 'sweep-tick'; frozen: boolean; reason?: string } - | { type: 'auth-state'; state: import('../integrations/auth-state.js').AuthState; msRemaining?: number } | { type: 'server-status'; entries: Array<{ server: string; players: number | null; name?: string }> }; class Bus extends EventEmitter { diff --git a/src/lib/server/pipeline/sweep.ts b/src/lib/server/pipeline/sweep.ts index e8b2408..eb27047 100644 --- a/src/lib/server/pipeline/sweep.ts +++ b/src/lib/server/pipeline/sweep.ts @@ -1,9 +1,9 @@ -import { config, iw4mConfigured, komodoConfigured } from '../config.js'; +import { config, komodoConfigured, rconConfigured } from '../config.js'; import { db } from '../db/index.js'; import { emit } from './bus.js'; -import { fetchStatus, playersOn } from '../integrations/iw4m.js'; +import { rconGetPlayerCount } from '../integrations/rcon.js'; import { komodoRestart } from '../integrations/komodo.js'; -import { pipelineReady } from '../integrations/auth-state.js'; +import { setServerField } from '../fleet/servers.js'; interface PendingRow { deployment_id: string; @@ -16,24 +16,21 @@ function nowIso(): string { } function integrationsOn(): boolean { - return komodoConfigured() && iw4mConfigured(); + return komodoConfigured() && rconConfigured(); } /** * Re-check every pending/failed deployment_server row, restart the ones with - * zero players, mark the busy ones held. Same-server dedup is per-sweep, so - * one restart resolves every pending row for that server. Ports `deploy_sweep` - * in push_scripts.sh:705. + * zero players, hold the busy ones. Same-server dedup is per-sweep, so one + * restart resolves every pending row for that server. Ports `deploy_sweep` + * in push_scripts.sh:705 but talks RCON directly instead of routing through + * IW4MAdmin. */ -export async function sweep(): Promise<{ checked: number; restarted: number; held: number; failed: number; frozen: boolean }> { - if (!pipelineReady()) { - emit({ type: 'sweep-tick', frozen: true, reason: 'iw4m token stale' }); - return { checked: 0, restarted: 0, held: 0, failed: 0, frozen: true }; - } - +export async function sweep(): Promise<{ checked: number; restarted: number; held: number; failed: number }> { if (!integrationsOn()) { - // Same behavior as line 714 — mark rows manual so they don't hang - // forever if integrations were never configured. + // Same shape as the shell script's line 714: mark rows manual so + // they don't hang forever waiting for something that will never + // arrive. db() .prepare( `UPDATE deployment_servers @@ -41,7 +38,8 @@ export async function sweep(): Promise<{ checked: number; restarted: number; hel WHERE restart_state IN ('pending','failed')` ) .run(nowIso()); - return { checked: 0, restarted: 0, held: 0, failed: 0, frozen: false }; + emit({ type: 'sweep-tick', frozen: false, reason: 'integrations not configured' }); + return { checked: 0, restarted: 0, held: 0, failed: 0 }; } const pending = db() @@ -51,19 +49,9 @@ export async function sweep(): Promise<{ checked: number; restarted: number; hel WHERE restart_state IN ('pending','failed')` ) .all() as PendingRow[]; - if (pending.length === 0) return { checked: 0, restarted: 0, held: 0, failed: 0, frozen: false }; - - // Warm the /api/status cache so per-server calls just hit memory - const status = await fetchStatus(); - if (!status.ok) { - // Same as line 742 — mark held with the iw4m error - const stmt = db().prepare( - `UPDATE deployment_servers - SET checked_at = ?, error = ?, attempts = attempts + 1 - WHERE restart_state IN ('pending','failed')` - ); - stmt.run(nowIso(), `iw4m: ${status.message}`); - return { checked: 0, restarted: 0, held: pending.length, failed: 0, frozen: false }; + if (pending.length === 0) { + emit({ type: 'sweep-tick', frozen: false }); + return { checked: 0, restarted: 0, held: 0, failed: 0 }; } // Dedup across deployments: if we resolved server X once this tick, @@ -87,9 +75,9 @@ export async function sweep(): Promise<{ checked: number; restarted: number; hel let outcome = seen.get(row.server); if (!outcome) { checked++; - const players = await playersOn(row.server); + const players = await rconGetPlayerCount(row.server); if (players === null) { - outcome = { state: 'pending', players: null, error: 'no iw4m match' }; + outcome = { state: 'pending', players: null, error: 'rcon unreachable' }; held++; } else if (players > 0) { outcome = { state: 'pending', players, error: null }; @@ -108,6 +96,12 @@ export async function sweep(): Promise<{ checked: number; restarted: number; hel } } seen.set(row.server, outcome); + // Cache the last known player count on the server row so the + // dashboard can render a value between rcon polls. + if (outcome.players !== null) { + setServerField(row.server, 'last_players', outcome.players); + setServerField(row.server, 'last_checked_at', nowIso()); + } } const now = nowIso(); applyOutcome.run( @@ -159,5 +153,5 @@ export async function sweep(): Promise<{ checked: number; restarted: number; hel .run(cutoff); emit({ type: 'sweep-tick', frozen: false }); - return { checked, restarted, held, failed, frozen: false }; + return { checked, restarted, held, failed }; } diff --git a/src/lib/server/pipeline/worker.ts b/src/lib/server/pipeline/worker.ts index ac74f3a..60bb2cf 100644 --- a/src/lib/server/pipeline/worker.ts +++ b/src/lib/server/pipeline/worker.ts @@ -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 { } } -async function pushServerStatus(): Promise { - 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 { + 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 { + 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'); } diff --git a/src/lib/ui/components/PipelineTimeline.svelte b/src/lib/ui/components/PipelineTimeline.svelte index 04693f1..36d8ca5 100644 --- a/src/lib/ui/components/PipelineTimeline.svelte +++ b/src/lib/ui/components/PipelineTimeline.svelte @@ -1,5 +1,5 @@ - -{#if showModal} - -{/if} - - diff --git a/src/lib/ui/stores/auth.ts b/src/lib/ui/stores/auth.ts deleted file mode 100644 index a8e7a74..0000000 --- a/src/lib/ui/stores/auth.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { writable, derived } from 'svelte/store'; -import { browser } from '$app/environment'; - -export type AuthState = - | { kind: 'ready'; token: string; expiresAt: number } - | { kind: 'stale'; reason: 'expired' | 'rejected' | 'unreachable' } - | { kind: 'missing' }; - -interface AuthPayload { - type: 'auth-state'; - state: AuthState; - msRemaining?: number; -} - -export const authState = writable({ kind: 'missing' }); -export const msRemaining = writable(0); - -export const isReady = derived(authState, ($s) => $s.kind === 'ready'); -export const shouldBlock = derived(authState, ($s) => $s.kind !== 'ready'); - -let source: EventSource | null = null; - -export function startAuthStream(): void { - if (!browser || source) return; - source = new EventSource('/api/events/auth'); - source.addEventListener('auth-state', (evt) => { - try { - const payload = JSON.parse((evt as MessageEvent).data) as AuthPayload; - authState.set(payload.state); - if (payload.msRemaining !== undefined) msRemaining.set(payload.msRemaining); - } catch { - /* ignore malformed */ - } - }); - source.addEventListener('error', () => { - // Browser will auto-reconnect. If the stream stays down long enough, - // the token will expire on the server anyway and the next reconnect - // will surface the transition. - }); -} - -export async function submitToken(token: string): Promise<{ ok: true } | { ok: false; error: string }> { - const res = await fetch('/api/auth/token', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token }) - }); - const body = (await res.json()) as { ok: boolean; error?: string }; - if (!res.ok || !body.ok) return { ok: false, error: body.error ?? `HTTP ${res.status}` }; - return { ok: true }; -} diff --git a/src/lib/ui/stores/pipeline.ts b/src/lib/ui/stores/pipeline.ts index f041f8d..f30d253 100644 --- a/src/lib/ui/stores/pipeline.ts +++ b/src/lib/ui/stores/pipeline.ts @@ -11,8 +11,6 @@ export interface PipelineDeployment { } export const deployments = writable>(new Map()); -export const frozen = writable(false); -export const frozenReason = writable(null); let source: EventSource | null = null; @@ -51,10 +49,4 @@ export function startPipelineStream(): void { d.servers.set(data.server, { state: data.state, players: data.players, error: data.error }); }); }); - - source.addEventListener('sweep-tick', (evt) => { - const data = JSON.parse((evt as MessageEvent).data) as { frozen: boolean; reason?: string }; - frozen.set(data.frozen); - frozenReason.set(data.reason ?? null); - }); } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index bb27fdb..aa6c3fa 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,16 +1,13 @@