Initial commit — SvelteKit fleet manager for T5 Plutonium servers

Ports push_scripts.sh (1497 lines of bash) to a SvelteKit + SQLite web app
that manages a fleet of T5 Plutonium servers running on Komodo. Same push
and beta workflow as the shell script, with a live SSE pipeline view, a
!rt-token-gated auth modal that hard-pauses deployments on stale tokens,
per-file audit diffs, and a Komodo-container deployment target.

- Push/beta engines port the exact filter, held-by-beta, conflict, and
  stale-backup rules from the shell script
- SQLite (better-sqlite3) replaces script_metadata.json; a one-shot
  importer moves legacy state over on first boot
- 16 vitest tests cover push add/update/skip, beta open→rollback,
  open→promote, conflict guards, sweep frozen/manual branches, and IW4M
  login/status parsing
- docker/ + komodo/stack.toml deploy as a managed container on the same
  host as the game servers, /root/zombies bind-mounted at the same path
  the shell script used
This commit is contained in:
2026-09-12 00:23:51 -04:00
commit d99711563e
67 changed files with 9415 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
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
@@ -0,0 +1,176 @@
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) };
}
+81
View File
@@ -0,0 +1,81 @@
import { config, komodoConfigured } from '../config.js';
import { getServer } from '../fleet/servers.js';
// Ports `komodo_restart` in push_scripts.sh:622. Three shapes depending on
// the tracked komodo_type: deployment | container | stack.
export type KomodoRestartResult =
| { ok: true }
| { ok: false; kind: 'unconfigured' | 'no-name' | 'http-error' | 'unreachable'; message: string };
async function komodoCall(path: string, body: unknown): Promise<Response> {
const url = `${config.komodo.address.replace(/\/+$/, '')}/${path}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.httpTimeoutMs);
try {
return await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': config.komodo.apiKey,
'X-Api-Secret': config.komodo.apiSecret
},
body: JSON.stringify(body),
signal: controller.signal
});
} finally {
clearTimeout(timer);
}
}
export async function komodoRestart(serverName: string): Promise<KomodoRestartResult> {
if (!komodoConfigured()) {
return { ok: false, kind: 'unconfigured', message: 'komodo not configured' };
}
const row = getServer(serverName);
if (!row?.komodo_name) return { ok: false, kind: 'no-name', message: 'no komodo_name set for server' };
const type = row.komodo_type ?? 'deployment';
let path: string;
let body: Record<string, string>;
switch (type) {
case 'stack':
path = 'execute/RestartStack';
body = { stack: row.komodo_name };
break;
case 'container':
if (!row.komodo_server) return { ok: false, kind: 'no-name', message: 'container type needs komodo_server' };
path = 'execute/RestartContainer';
body = { server: row.komodo_server, container: row.komodo_name };
break;
case 'deployment':
default:
path = 'execute/RestartDeployment';
body = { deployment: row.komodo_name };
break;
}
let res: Response;
try {
res = await komodoCall(path, body);
} catch (err) {
return { ok: false, kind: 'unreachable', message: (err as Error).message };
}
if (!res.ok) {
return { ok: false, kind: 'http-error', message: `HTTP ${res.status}` };
}
// The script also checks the response body: if it's an object with
// .success === false, treat as failure. If .success is absent, trust the
// HTTP status (line 646).
try {
const json = await res.json();
if (json && typeof json === 'object' && 'success' in json && json.success === false) {
return { ok: false, kind: 'http-error', message: 'komodo returned success=false' };
}
} catch {
// Non-JSON body is fine — HTTP 2xx already indicates success.
}
return { ok: true };
}