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:
+7
-5
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|||||||
@@ -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'
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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) };
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user