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
+7 -5
View File
@@ -14,10 +14,12 @@ KOMODO_ADDRESS=https://komodo.example.internal
KOMODO_API_KEY= KOMODO_API_KEY=
KOMODO_API_SECRET= KOMODO_API_SECRET=
# IW4MAdmin # RCON — shared across the fleet (every server uses the same rcon_password).
IW4M_ADDRESS=http://iw4m.example.internal # Host defaults to host.docker.internal so the container can reach game
IW4M_CLIENT_ID= # servers on the same Docker host; override if RCON lives elsewhere.
# no password here — !rt token is provided at runtime through the modal RCON_PASSWORD=
RCON_HOST=host.docker.internal
RCON_TIMEOUT_MS=3000
# HTTP # HTTP (Komodo API)
HTTP_TIMEOUT_MS=10000 HTTP_TIMEOUT_MS=10000
+39 -18
View File
@@ -2,15 +2,17 @@
Web app that manages a fleet of T5 Plutonium servers running under Komodo. 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 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 tests, restart empty servers, track deployments) with a live UI and real
diffs, and a hard pause when the IW4MAdmin `!rt` token goes stale. audit diffs.
## Stack ## Stack
- SvelteKit (adapter-node) + TypeScript - SvelteKit (adapter-node) + TypeScript
- SQLite via better-sqlite3 (replaces `script_metadata.json` as the source of - SQLite via better-sqlite3 (replaces `script_metadata.json` as the source of
truth) 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 - Runs as a single Komodo-managed container on the same host as the game
servers, with `/root/zombies` bind-mounted in 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/db/` — schema, migrations, better-sqlite3 handle
- `src/lib/server/fleet/` — file walking, push/beta engines - `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/lib/server/pipeline/` — sweep loop, event bus, worker singleton
- `src/routes/api/` — REST + SSE endpoints - `src/routes/api/` — REST + SSE endpoints
- `src/routes/` — UI pages (dashboard, deployments, betas, per-server) - `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 - `komodo/` — Komodo stack definition
- `scripts/` — legacy JSON importer, fixture seeder - `scripts/` — legacy JSON importer, fixture seeder
- `tests/` — vitest unit tests - `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 ## Local dev
Windows or macOS — no `/root/zombies` on disk, so seed a fixture tree first. 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 # open http://localhost:3000
``` ```
The `!rt` modal will block deployments until you paste a valid token. In Without RCON_PASSWORD / KOMODO_* set, the pipeline marks pending rows
local dev you can bypass it for exploration by leaving `IW4M_ADDRESS` unset `manual` after a sweep — the UI still works, you just don't get real
and calling `sweep()` in a test that force-transitions the auth state (see restarts. Enough to develop the push/beta flow.
`tests/unit/sweep.test.ts`).
## Tests ## Tests
@@ -56,8 +70,8 @@ npm test
``` ```
Covers push (add/update/skip), beta lifecycle (open → rollback, open → Covers push (add/update/skip), beta lifecycle (open → rollback, open →
promote, conflict blocks), sweep (frozen / manual branches), and IW4M promote, conflict blocks), sweep (integrations-off manual branch), and
login/status parsing (fetch mocked). RCON parsing (fake UDP socket).
## Deploy to the Komodo host ## Deploy to the Komodo host
@@ -67,16 +81,23 @@ login/status parsing (fetch mocked).
docker compose build docker compose build
``` ```
2. Import the shape of the stack into Komodo using `komodo/stack.toml` (or 2. Import `komodo/stack.toml` into Komodo (or point Komodo at the
copy the compose file into a Komodo stack). `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 `/root/zombies/script_metadata.json` exists and `/data/cherry.db` is
empty, `docker/entrypoint.sh` runs `scripts/import-legacy.ts` before empty, `entrypoint.sh` runs `scripts/import-legacy.ts` before starting
starting the server. The source JSON gets renamed to `.migrated`. 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). 5. Load the app on the LAN or over tailscale (no web-app auth by design).
Paste an `!rt` token to unfreeze the pipeline.
## What still lives in the shell script ## What still lives in the shell script
+8 -2
View File
@@ -7,6 +7,11 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3000:3000" - "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: volumes:
# in prod (Komodo host), the game server tree is at /root/zombies # in prod (Komodo host), the game server tree is at /root/zombies
- /root/zombies:/root/zombies:rw - /root/zombies:/root/zombies:rw
@@ -21,5 +26,6 @@ services:
KOMODO_ADDRESS: ${KOMODO_ADDRESS} KOMODO_ADDRESS: ${KOMODO_ADDRESS}
KOMODO_API_KEY: ${KOMODO_API_KEY} KOMODO_API_KEY: ${KOMODO_API_KEY}
KOMODO_API_SECRET: ${KOMODO_API_SECRET} KOMODO_API_SECRET: ${KOMODO_API_SECRET}
IW4M_ADDRESS: ${IW4M_ADDRESS} RCON_PASSWORD: ${RCON_PASSWORD}
IW4M_CLIENT_ID: ${IW4M_CLIENT_ID} RCON_HOST: ${RCON_HOST:-host.docker.internal}
RCON_TIMEOUT_MS: ${RCON_TIMEOUT_MS:-3000}
+9 -2
View File
@@ -16,6 +16,10 @@ volumes = [
"/root/zombies/.electric-cherry:/data:rw" "/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] [stack.services.web.env]
SHARED_SCRIPTS = "/root/zombies/shared/scripts" SHARED_SCRIPTS = "/root/zombies/shared/scripts"
SERVERS_ROOT = "/root/zombies/t5" SERVERS_ROOT = "/root/zombies/t5"
@@ -28,5 +32,8 @@ DEPLOY_KEEP_DAYS = "7"
KOMODO_ADDRESS = "https://komodo.example.internal" KOMODO_ADDRESS = "https://komodo.example.internal"
KOMODO_API_KEY = "" KOMODO_API_KEY = ""
KOMODO_API_SECRET = "" 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"
+11 -5
View File
@@ -26,9 +26,15 @@ export const config = {
apiKey: env.KOMODO_API_KEY ?? '', apiKey: env.KOMODO_API_KEY ?? '',
apiSecret: env.KOMODO_API_SECRET ?? '' apiSecret: env.KOMODO_API_SECRET ?? ''
}, },
iw4m: { rcon: {
address: env.IW4M_ADDRESS ?? '', // Host every RCON packet is sent to. Defaults to the Docker
clientId: env.IW4M_CLIENT_ID ?? '' // 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; } as const;
@@ -36,8 +42,8 @@ export function komodoConfigured(): boolean {
return !!(config.komodo.address && config.komodo.apiKey && config.komodo.apiSecret); return !!(config.komodo.address && config.komodo.apiKey && config.komodo.apiSecret);
} }
export function iw4mConfigured(): boolean { export function rconConfigured(): boolean {
return !!(config.iw4m.address && config.iw4m.clientId); return !!config.rcon.password;
} }
export const betaSrc = () => `${config.sharedScripts}/betas`; export const betaSrc = () => `${config.sharedScripts}/betas`;
+2 -2
View File
@@ -9,8 +9,8 @@ CREATE TABLE IF NOT EXISTS servers (
komodo_type TEXT NOT NULL DEFAULT 'deployment', -- deployment|stack|container komodo_type TEXT NOT NULL DEFAULT 'deployment', -- deployment|stack|container
komodo_name TEXT NOT NULL, komodo_name TEXT NOT NULL,
komodo_server TEXT, komodo_server TEXT,
iw4m_match TEXT, -- port|id|name game_port INTEGER, -- UDP RCON port; lazy-filled from Komodo
iw4m_value TEXT, rcon_host TEXT, -- optional per-server override of RCON_HOST
last_players INTEGER, last_players INTEGER,
last_checked_at TEXT, last_checked_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
+4 -4
View File
@@ -67,8 +67,8 @@ export interface ServerRow {
komodo_type: 'deployment' | 'stack' | 'container'; komodo_type: 'deployment' | 'stack' | 'container';
komodo_name: string; komodo_name: string;
komodo_server: string | null; komodo_server: string | null;
iw4m_match: 'port' | 'id' | 'name' | null; game_port: number | null;
iw4m_value: string | null; rcon_host: string | null;
last_players: number | null; last_players: number | null;
last_checked_at: string | null; last_checked_at: string | null;
created_at: string; created_at: string;
@@ -113,8 +113,8 @@ export function setServerField(
'komodo_type', 'komodo_type',
'komodo_name', 'komodo_name',
'komodo_server', 'komodo_server',
'iw4m_match', 'game_port',
'iw4m_value', 'rcon_host',
'last_players', 'last_players',
'last_checked_at' 'last_checked_at'
]; ];
-54
View File
@@ -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<typeof emit>[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;
}
-176
View File
@@ -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<Response> {
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<string, string> = {};
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<string, unknown>;
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<number | null> {
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) };
}
+102
View File
@@ -79,3 +79,105 @@ export async function komodoRestart(serverName: string): Promise<KomodoRestartRe
} }
return { ok: true }; 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;
}
+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
};
}
+3 -4
View File
@@ -1,8 +1,8 @@
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
// One in-process event bus, multiplexed by all three SSE endpoints. Kept // One in-process event bus, multiplexed by the SSE endpoints. Kept separate
// separate from any specific subscriber so push/beta/sweep can emit without // from any specific subscriber so push/beta/sweep can emit without pulling
// pulling the whole worker module in. // the whole worker module in.
export type PipelineEvent = export type PipelineEvent =
| { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] } | { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] }
@@ -16,7 +16,6 @@ export type PipelineEvent =
error: string | null; error: string | null;
} }
| { type: 'sweep-tick'; frozen: boolean; reason?: string } | { 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 }> }; | { type: 'server-status'; entries: Array<{ server: string; players: number | null; name?: string }> };
class Bus extends EventEmitter { class Bus extends EventEmitter {
+26 -32
View File
@@ -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 { db } from '../db/index.js';
import { emit } from './bus.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 { komodoRestart } from '../integrations/komodo.js';
import { pipelineReady } from '../integrations/auth-state.js'; import { setServerField } from '../fleet/servers.js';
interface PendingRow { interface PendingRow {
deployment_id: string; deployment_id: string;
@@ -16,24 +16,21 @@ function nowIso(): string {
} }
function integrationsOn(): boolean { function integrationsOn(): boolean {
return komodoConfigured() && iw4mConfigured(); return komodoConfigured() && rconConfigured();
} }
/** /**
* Re-check every pending/failed deployment_server row, restart the ones with * 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 * zero players, hold the busy ones. Same-server dedup is per-sweep, so one
* one restart resolves every pending row for that server. Ports `deploy_sweep` * restart resolves every pending row for that server. Ports `deploy_sweep`
* in push_scripts.sh:705. * 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 }> { export async function sweep(): Promise<{ checked: number; restarted: number; held: number; failed: number }> {
if (!pipelineReady()) {
emit({ type: 'sweep-tick', frozen: true, reason: 'iw4m token stale' });
return { checked: 0, restarted: 0, held: 0, failed: 0, frozen: true };
}
if (!integrationsOn()) { if (!integrationsOn()) {
// Same behavior as line 714 — mark rows manual so they don't hang // Same shape as the shell script's line 714: mark rows manual so
// forever if integrations were never configured. // they don't hang forever waiting for something that will never
// arrive.
db() db()
.prepare( .prepare(
`UPDATE deployment_servers `UPDATE deployment_servers
@@ -41,7 +38,8 @@ export async function sweep(): Promise<{ checked: number; restarted: number; hel
WHERE restart_state IN ('pending','failed')` WHERE restart_state IN ('pending','failed')`
) )
.run(nowIso()); .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() const pending = db()
@@ -51,19 +49,9 @@ export async function sweep(): Promise<{ checked: number; restarted: number; hel
WHERE restart_state IN ('pending','failed')` WHERE restart_state IN ('pending','failed')`
) )
.all() as PendingRow[]; .all() as PendingRow[];
if (pending.length === 0) return { checked: 0, restarted: 0, held: 0, failed: 0, frozen: false }; if (pending.length === 0) {
emit({ type: 'sweep-tick', frozen: false });
// Warm the /api/status cache so per-server calls just hit memory return { checked: 0, restarted: 0, held: 0, failed: 0 };
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 };
} }
// Dedup across deployments: if we resolved server X once this tick, // 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); let outcome = seen.get(row.server);
if (!outcome) { if (!outcome) {
checked++; checked++;
const players = await playersOn(row.server); const players = await rconGetPlayerCount(row.server);
if (players === null) { if (players === null) {
outcome = { state: 'pending', players: null, error: 'no iw4m match' }; outcome = { state: 'pending', players: null, error: 'rcon unreachable' };
held++; held++;
} else if (players > 0) { } else if (players > 0) {
outcome = { state: 'pending', players, error: null }; 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); 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(); const now = nowIso();
applyOutcome.run( applyOutcome.run(
@@ -159,5 +153,5 @@ export async function sweep(): Promise<{ checked: number; restarted: number; hel
.run(cutoff); .run(cutoff);
emit({ type: 'sweep-tick', frozen: false }); emit({ type: 'sweep-tick', frozen: false });
return { checked, restarted, held, failed, frozen: false }; return { checked, restarted, held, failed };
} }
+29 -30
View File
@@ -1,11 +1,13 @@
import { sweep } from './sweep.js'; import { sweep } from './sweep.js';
import { emit } from './bus.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 { listServers, seedServerRegistry } from '../fleet/servers.js';
import { getAuthState } from '../integrations/auth-state.js';
const TICK_MS = 15_000; const TICK_MS = 15_000;
const STATUS_REFRESH_MS = 30_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 started = false;
let tickTimer: NodeJS.Timeout | null = null; let tickTimer: NodeJS.Timeout | null = null;
@@ -19,44 +21,41 @@ async function tick(): Promise<void> {
} }
} }
async function pushServerStatus(): Promise<void> { async function fanoutStatus(): Promise<void> {
const st = await fetchStatus(); const servers = listServers();
if (!st.ok) return; if (servers.length === 0) return;
const rows = listServers();
const byName = new Map(rows.map((r) => [r.name, r])); const entries: Array<{ server: string; players: number | null; name?: string }> = new Array(servers.length);
// For the dashboard: emit a lightweight snapshot. Actual per-server let cursor = 0;
// resolution is handled by playersOn() during a sweep, which caches
// iw4m_match/value into the servers table. async function pump(): Promise<void> {
emit({ while (true) {
type: 'server-status', const i = cursor++;
entries: st.entries.map((e) => ({ if (i >= servers.length) return;
server: e.name ?? String(e.port ?? ''), const srv = servers[i];
players: e.currentPlayers ?? null, const status = await rconGetStatus(srv.name).catch(() => null);
name: e.name entries[i] = {
})) server: srv.name,
}); players: status?.players ?? null,
// Also fold current auth ttl into the auth channel so subscribers get a name: status?.map ?? undefined
// heartbeat and a countdown without waiting for a transition. };
const auth = getAuthState(); }
emit({ }
type: 'auth-state', await Promise.all(Array.from({ length: STATUS_CONCURRENCY }, pump));
state: auth,
msRemaining: auth.kind === 'ready' ? Math.max(0, auth.expiresAt - Date.now()) : undefined emit({ type: 'server-status', entries });
});
void byName; // reserved: future join between /api/status and server registry
} }
export function startWorker(): void { export function startWorker(): void {
if (started) return; if (started) return;
started = true; 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)); seedServerRegistry().catch((err) => console.error('[worker] seed error:', err));
void tick(); void tick();
void pushServerStatus(); void fanoutStatus();
tickTimer = setInterval(tick, TICK_MS); 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'); console.log('[worker] started — sweep every', TICK_MS, 'ms, status every', STATUS_REFRESH_MS, 'ms');
} }
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { deployments, frozen, frozenReason } from '$ui/stores/pipeline.js'; import { deployments } from '$ui/stores/pipeline.js';
// Newest first, up to 8 rows on the dashboard. // Newest first, up to 8 rows on the dashboard.
let rows = $derived( let rows = $derived(
@@ -32,9 +32,6 @@
<div class="wrapper"> <div class="wrapper">
<header> <header>
<h3>Pipeline</h3> <h3>Pipeline</h3>
{#if $frozen}
<span class="frozen">frozen — {$frozenReason ?? 'no reason'}</span>
{/if}
</header> </header>
{#if rows.length === 0} {#if rows.length === 0}
<p class="empty">no active deployments</p> <p class="empty">no active deployments</p>
@@ -77,7 +74,6 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
h3 { margin: 0; font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: #aaa; } h3 { margin: 0; font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: #aaa; }
.frozen { color: #ff6060; font-size: 12px; }
.empty { color: #666; font-size: 13px; margin: 0; } .empty { color: #666; font-size: 13px; margin: 0; }
.rows { display: grid; gap: 8px; } .rows { display: grid; gap: 8px; }
.row { .row {
-160
View File
@@ -1,160 +0,0 @@
<script lang="ts">
import { authState, msRemaining, shouldBlock, submitToken } from '$ui/stores/auth.js';
let value = $state('');
let submitting = $state(false);
let error = $state<string | null>(null);
async function onSubmit(e: Event) {
e.preventDefault();
if (submitting) return;
submitting = true;
error = null;
const res = await submitToken(value);
submitting = false;
if (!res.ok) {
error = res.error;
return;
}
value = '';
}
// Reactively re-open the modal when we cross the warning threshold. The
// server pushes msRemaining every 5s; that's enough for the UI to notice
// "about to expire" and coax the user in-game before things freeze.
let warnOpen = $state(false);
$effect(() => {
if ($authState.kind === 'ready' && $msRemaining < 20_000 && $msRemaining > 0) {
warnOpen = true;
} else {
warnOpen = false;
}
});
let showModal = $derived($shouldBlock || warnOpen);
</script>
{#if showModal}
<div class="scrim" role="dialog" aria-modal="true" aria-labelledby="tm-title">
<div class="modal">
<h2 id="tm-title">
{#if $shouldBlock}
Deployments paused
{:else}
Token expiring
{/if}
</h2>
<p class="explain">
{#if $authState.kind === 'missing'}
Run <code>!rt</code> in-game to generate a 4-character token, then paste it here.
{:else if $authState.kind === 'stale'}
The last token was {$authState.reason}. Generate a new one with <code>!rt</code> in-game.
{:else}
Your token expires in {Math.ceil($msRemaining / 1000)}s. Paste a new one to keep deployments flowing.
{/if}
</p>
<form onsubmit={onSubmit}>
<input
type="text"
inputmode="text"
autocomplete="off"
autocorrect="off"
spellcheck="false"
maxlength="4"
minlength="4"
pattern="[a-zA-Z0-9]{'{'}4{'}'}"
placeholder="aB3d"
bind:value
disabled={submitting}
aria-label="4-character !rt token"
/>
<button type="submit" disabled={submitting || value.length !== 4}>
{submitting ? '…' : 'unfreeze'}
</button>
</form>
{#if error}
<p class="error">{error}</p>
{/if}
<p class="hint">
The <code>!rt</code> token is single-use and dies after 2 minutes. That's normal.
</p>
</div>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: grid;
place-items: center;
z-index: 9999;
backdrop-filter: blur(2px);
}
.modal {
background: #111;
color: #eee;
border: 1px solid #444;
border-radius: 8px;
padding: 24px;
min-width: 360px;
max-width: 460px;
font-family: system-ui, -apple-system, sans-serif;
}
h2 {
margin: 0 0 8px;
font-size: 18px;
color: #ff6060;
}
.explain {
margin: 0 0 16px;
font-size: 14px;
color: #ccc;
line-height: 1.4;
}
form {
display: flex;
gap: 8px;
}
input {
flex: 1;
background: #000;
color: #eee;
border: 1px solid #666;
border-radius: 4px;
padding: 10px 12px;
font-size: 20px;
font-family: 'JetBrains Mono', ui-monospace, monospace;
letter-spacing: 8px;
text-align: center;
}
button {
background: #ff3030;
color: #fff;
border: 0;
border-radius: 4px;
padding: 0 16px;
font-weight: 600;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
.error {
color: #ff6060;
margin: 12px 0 0;
font-size: 13px;
}
.hint {
margin: 16px 0 0;
font-size: 12px;
color: #888;
}
code {
background: #222;
padding: 1px 5px;
border-radius: 3px;
}
</style>
-51
View File
@@ -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<AuthState>({ kind: 'missing' });
export const msRemaining = writable<number>(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 };
}
-8
View File
@@ -11,8 +11,6 @@ export interface PipelineDeployment {
} }
export const deployments = writable<Map<string, PipelineDeployment>>(new Map()); export const deployments = writable<Map<string, PipelineDeployment>>(new Map());
export const frozen = writable<boolean>(false);
export const frozenReason = writable<string | null>(null);
let source: EventSource | null = 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 }); 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);
});
} }
-5
View File
@@ -1,16 +1,13 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { startAuthStream } from '$ui/stores/auth.js';
import { startPipelineStream } from '$ui/stores/pipeline.js'; import { startPipelineStream } from '$ui/stores/pipeline.js';
import { startServerStream } from '$ui/stores/servers.js'; import { startServerStream } from '$ui/stores/servers.js';
import TokenModal from '$ui/components/TokenModal.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
interface Props { children: import('svelte').Snippet } interface Props { children: import('svelte').Snippet }
let { children }: Props = $props(); let { children }: Props = $props();
onMount(() => { onMount(() => {
startAuthStream();
startPipelineStream(); startPipelineStream();
startServerStream(); startServerStream();
}); });
@@ -28,8 +25,6 @@
</main> </main>
</div> </div>
<TokenModal />
<style> <style>
:global(html, body) { :global(html, body) {
margin: 0; margin: 0;
+2 -2
View File
@@ -9,8 +9,8 @@
name: string; name: string;
komodo_type: string; komodo_type: string;
komodo_name: string; komodo_name: string;
iw4m_match: string | null; game_port: number | null;
iw4m_value: string | null; rcon_host: string | null;
last_players: number | null; last_players: number | null;
held: HeldRow[]; held: HeldRow[];
} }
-30
View File
@@ -1,30 +0,0 @@
import { json } from '@sveltejs/kit';
import { loginWithToken } from '$lib/server/integrations/iw4m.js';
import { getAuthState, setAuthState } from '$lib/server/integrations/auth-state.js';
import { kick } from '$lib/server/pipeline/worker.js';
export async function GET() {
return json(getAuthState());
}
export async function POST({ request }: { request: Request }) {
const body = (await request.json().catch(() => ({}))) as { token?: string };
const token = (body.token ?? '').trim();
if (!/^[a-zA-Z0-9]{4}$/.test(token)) {
return json({ ok: false, error: 'token must be exactly 4 alphanumeric characters' }, { status: 400 });
}
const res = await loginWithToken(token);
if (!res.ok) {
setAuthState({ kind: 'stale', reason: res.reason === 'rejected' ? 'rejected' : 'unreachable' });
return json({ ok: false, error: res.message }, { status: res.reason === 'rejected' ? 401 : 502 });
}
setAuthState({ kind: 'ready', token, cookie: res.cookie, expiresAt: res.expiresAt });
// Fresh cookie → immediately drain any queued restarts.
void kick();
return json({ ok: true, expiresAt: res.expiresAt });
}
export async function DELETE() {
setAuthState({ kind: 'missing' });
return json({ ok: true });
}
-47
View File
@@ -1,47 +0,0 @@
import { subscribe } from '$lib/server/pipeline/bus.js';
import { getAuthState } from '$lib/server/integrations/auth-state.js';
const encoder = new TextEncoder();
const HEARTBEAT_MS = 5_000;
export async function GET() {
let heartbeat: NodeJS.Timeout | null = null;
let unsubscribe: (() => void) | null = null;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const push = () => {
const s = getAuthState();
const payload = {
type: 'auth-state',
state: s,
msRemaining: s.kind === 'ready' ? Math.max(0, s.expiresAt - Date.now()) : undefined
};
controller.enqueue(encoder.encode(`event: auth-state\ndata: ${JSON.stringify(payload)}\n\n`));
};
unsubscribe = subscribe((event) => {
if (event.type === 'auth-state') {
controller.enqueue(encoder.encode(`event: auth-state\ndata: ${JSON.stringify(event)}\n\n`));
}
});
push();
// The auth stream double-serves as the token-countdown feed; the
// frontend uses the msRemaining to render a per-second ticker,
// so we push every 5s even without a state change.
heartbeat = setInterval(push, HEARTBEAT_MS);
},
cancel() {
if (heartbeat) clearInterval(heartbeat);
if (unsubscribe) unsubscribe();
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive'
}
});
}
+8 -4
View File
@@ -8,8 +8,8 @@
komodo_type: string; komodo_type: string;
komodo_name: string; komodo_name: string;
komodo_server: string | null; komodo_server: string | null;
iw4m_match: string | null; game_port: number | null;
iw4m_value: string | null; rcon_host: string | null;
last_players: number | null; last_players: number | null;
last_checked_at: string | null; last_checked_at: string | null;
held: Array<{ target: string; slug: string }>; held: Array<{ target: string; slug: string }>;
@@ -49,8 +49,12 @@
<div class="v">{server.komodo_type} · {server.komodo_name}</div> <div class="v">{server.komodo_type} · {server.komodo_name}</div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="k">iw4m</div> <div class="k">rcon</div>
<div class="v">{server.iw4m_match ?? '—'} · {server.iw4m_value ?? '—'}</div> <div class="v">{server.rcon_host ?? 'default'} · port {server.game_port ?? '—'}</div>
</div>
<div class="stat">
<div class="k">last check</div>
<div class="v">{server.last_checked_at ?? '—'}</div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="k">held by beta</div> <div class="k">held by beta</div>
-72
View File
@@ -1,72 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture();
process.env.IW4M_ADDRESS = 'http://iw4m.test';
process.env.IW4M_CLIENT_ID = '1';
vi.stubGlobal('fetch', vi.fn());
});
afterEach(async () => {
await fixture.cleanup();
vi.unstubAllGlobals();
});
describe('loginWithToken', () => {
it('returns cookie + expiry on 2xx', async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response('', {
status: 200,
headers: { 'Set-Cookie': '.AspNetCore.Cookies=abc; path=/; httponly' }
})
);
const { loginWithToken } = await import('../../src/lib/server/integrations/iw4m.js');
const res = await loginWithToken('abcd');
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.cookie).toContain('.AspNetCore.Cookies=abc');
expect(res.expiresAt).toBeGreaterThan(Date.now());
}
});
it('returns rejected on 401', async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(new Response('', { status: 401 }));
const { loginWithToken } = await import('../../src/lib/server/integrations/iw4m.js');
const res = await loginWithToken('abcd');
expect(res.ok).toBe(false);
if (!res.ok) expect(res.reason).toBe('rejected');
});
it('returns unreachable on network error', async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('ECONNREFUSED'));
const { loginWithToken } = await import('../../src/lib/server/integrations/iw4m.js');
const res = await loginWithToken('abcd');
expect(res.ok).toBe(false);
if (!res.ok) expect(res.reason).toBe('unreachable');
});
});
describe('fetchStatus', () => {
it('normalizes camelCase / PascalCase entries', async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response(
JSON.stringify([
{ id: 1, port: 3072, name: 'kino der toten', currentPlayers: 3 },
{ Id: 2, Port: 3073, Name: 'ascension', CurrentPlayers: 0 }
]),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
);
const { fetchStatus } = await import('../../src/lib/server/integrations/iw4m.js');
const res = await fetchStatus();
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.entries[0].currentPlayers).toBe(3);
expect(res.entries[1].name).toBe('ascension');
}
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { extractUdpPort } from '../../src/lib/server/integrations/komodo.js';
describe('extractUdpPort', () => {
it('returns the published port from docker-compose-style object entries', () => {
const payload = {
state: {
container: {
ports: [
{ protocol: 'tcp', published: 3000, target: 3000 },
{ protocol: 'udp', published: 4976, target: 28960 }
]
}
}
};
expect(extractUdpPort(payload)).toBe(4976);
});
it('parses docker-run-style "host:container/proto" strings', () => {
const payload = { ports: ['3074:3074/tcp', '4977:28960/udp'] };
expect(extractUdpPort(payload)).toBe(4977);
});
it('picks the UDP entry even when TCP appears first', () => {
const payload = {
deeply: {
nested: {
ports: [
{ Protocol: 'tcp', PublishedPort: 80 },
{ Protocol: 'udp', PublishedPort: 5555 }
]
}
}
};
expect(extractUdpPort(payload)).toBe(5555);
});
it('returns null when no UDP mapping exists', () => {
const payload = { ports: [{ protocol: 'tcp', published: 3000 }] };
expect(extractUdpPort(payload)).toBeNull();
});
it('survives cyclic references', () => {
const cyc: Record<string, unknown> = {};
cyc.self = cyc;
expect(extractUdpPort(cyc)).toBeNull();
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { parseInfoLine, parsePlayerCount } from '../../src/lib/server/integrations/rcon.js';
describe('parsePlayerCount', () => {
it('counts rows after --- --- separator (status dialect)', () => {
const body = [
'print',
'map: mp_kino',
'num score ping guid name',
'--- ----- ---- -------- ----',
' 0 12 45 abc123def456 joe',
' 1 0 72 xyz789abc123 jill',
' 2 999 30 111222333444 kade'
].join('\n');
expect(parsePlayerCount(body)).toBe(3);
});
it('returns 0 when the scoreboard has no rows', () => {
const body = [
'print',
'map: mp_ascension',
'num score ping guid name',
'--- ----- ---- -------- ----',
''
].join('\n');
expect(parsePlayerCount(body)).toBe(0);
});
it('counts non-blank lines after the info line (statusResponse dialect)', () => {
const body = [
'statusResponse',
'\\mapname\\mp_kino\\g_gametype\\zom\\sv_maxclients\\4',
'10 45 "joe"',
'0 72 "jill"'
].join('\n');
expect(parsePlayerCount(body)).toBe(2);
});
it('returns null on malformed payloads with no separator or info line', () => {
expect(parsePlayerCount('nonsense body\nno structure here')).toBeNull();
});
});
describe('parseInfoLine', () => {
it('splits \\key\\value pairs from an info string', () => {
const body = [
'statusResponse',
'\\mapname\\mp_kino\\g_gametype\\zom\\sv_maxclients\\4'
].join('\n');
const info = parseInfoLine(body);
expect(info.mapname).toBe('mp_kino');
expect(info.g_gametype).toBe('zom');
expect(info.sv_maxclients).toBe('4');
});
it('returns empty object when no info line is present', () => {
expect(parseInfoLine('nothing here')).toEqual({});
});
});
+5 -12
View File
@@ -8,7 +8,7 @@ beforeEach(async () => {
fixture = await newFixture({ 'hud.gsc': 'v1' }, ['t5_kino_1', 't5_ascension_1']); fixture = await newFixture({ 'hud.gsc': 'v1' }, ['t5_kino_1', 't5_ascension_1']);
// No integrations → sweep marks rows manual, exercising that branch. // No integrations → sweep marks rows manual, exercising that branch.
delete process.env.KOMODO_ADDRESS; delete process.env.KOMODO_ADDRESS;
delete process.env.IW4M_ADDRESS; delete process.env.RCON_PASSWORD;
}); });
afterEach(async () => { afterEach(async () => {
@@ -20,19 +20,13 @@ describe('sweep', () => {
const { doPush } = await import('../../src/lib/server/fleet/push.js'); const { doPush } = await import('../../src/lib/server/fleet/push.js');
const { sweep } = await import('../../src/lib/server/pipeline/sweep.js'); const { sweep } = await import('../../src/lib/server/pipeline/sweep.js');
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
const { setAuthState } = await import('../../src/lib/server/integrations/auth-state.js');
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
await seedServerRegistry(); await seedServerRegistry();
// Force ready so pipelineReady() lets sweep run (even though
// integrations are off — that's the branch we want to test).
setAuthState({ kind: 'ready', token: 'abcd', cookie: 'x=y', expiresAt: Date.now() + 100_000 });
const push = await doPush({ patterns: ['*'] }); const push = await doPush({ patterns: ['*'] });
expect(push.deploymentId).not.toBeNull(); expect(push.deploymentId).not.toBeNull();
const result = await sweep(); await sweep();
expect(result.frozen).toBe(false);
const rows = db() const rows = db()
.prepare("SELECT restart_state FROM deployment_servers WHERE deployment_id = ?") .prepare("SELECT restart_state FROM deployment_servers WHERE deployment_id = ?")
@@ -40,14 +34,13 @@ describe('sweep', () => {
for (const r of rows) expect(r.restart_state).toBe('manual'); for (const r of rows) expect(r.restart_state).toBe('manual');
}); });
it('freezes when auth is not ready', async () => { it('is a no-op when nothing is pending', async () => {
const { doPush } = await import('../../src/lib/server/fleet/push.js');
const { sweep } = await import('../../src/lib/server/pipeline/sweep.js'); const { sweep } = await import('../../src/lib/server/pipeline/sweep.js');
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry(); await seedServerRegistry();
await doPush({ patterns: ['*'] }); // No deployments → no pending rows. Should return zero counts.
const result = await sweep(); const result = await sweep();
expect(result.frozen).toBe(true); expect(result).toEqual({ checked: 0, restarted: 0, held: 0, failed: 0 });
}); });
}); });