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 { 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 { 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; 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; } const url = `${config.komodo.address.replace(/\/+$/, '')}/${path}`; let res: Response; try { res = await komodoCall(path, body); } catch (err) { return { ok: false, kind: 'unreachable', message: describeFetchError(err, url) }; } 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 }; } // ---------- Port resolution ---------- export type KomodoPortFailure = | 'unconfigured' | 'no-name' | 'unreachable' // network / DNS / timeout | 'http-error' // non-2xx | 'unparseable' // 2xx but body wasn't JSON | 'no-udp-in-response'; // parsed but no UDP port found export interface KomodoPortSuccess { ok: true; port: number; snippet: string; url: string; } export interface KomodoPortFailureResult { ok: false; reason: KomodoPortFailure; detail: string; httpStatus?: number; snippet?: string; // URL we tried (or would have tried) — surfaced in the UI so operators // can `curl` it from the container to verify reachability. url?: string; } export type KomodoPortResult = KomodoPortSuccess | KomodoPortFailureResult; const SNIPPET_LIMIT = 4000; function truncate(s: string): string { if (s.length <= SNIPPET_LIMIT) return s; return s.slice(0, SNIPPET_LIMIT) + `\n… (${s.length - SNIPPET_LIMIT} more bytes)`; } /** * Turn a Node/Undici fetch failure into something an operator can act on. * The default `.message` for an aborted fetch is just "This operation was * aborted" — useless. For network failures Node hides the real cause under * `.cause` (e.g. `ECONNREFUSED`, `ENOTFOUND`, `EHOSTUNREACH`). Unwrap both * and always include the URL we tried. */ function describeFetchError(err: unknown, url: string): string { const e = err as (Error & { name?: string; cause?: unknown }) | undefined; if (e?.name === 'AbortError') { return `timed out after ${config.httpTimeoutMs}ms dialing ${url}`; } const cause = e?.cause as (Error & { code?: string }) | undefined; if (cause && (cause.code || cause.message)) { const code = cause.code ? `${cause.code}: ` : ''; return `${code}${cause.message ?? 'network error'} (dialing ${url})`; } return `${e?.message ?? String(err)} (dialing ${url})`; } function safeJsonStringify(value: unknown): string { try { return JSON.stringify(value, null, 2); } catch { return String(value); } } /** * Detailed variant of `komodoResolveGamePort` — returns a discriminated * union so callers can distinguish "network died" from "auth failed" from * "we parsed the JSON but couldn't find a UDP port in a shape we recognize". * * On success, the response snippet is included so the caller can render it * as "here's where we found the port". On no-udp-in-response, the snippet * is the only way we'll know what shape Komodo actually emitted — surface * it prominently in the UI so we can extend `extractUdpPort` for that shape. */ export async function komodoResolvePortDetailed(serverName: string): Promise { if (!komodoConfigured()) { return { ok: false, reason: 'unconfigured', detail: 'KOMODO_ADDRESS/KEY/SECRET are not all set' }; } const row = getServer(serverName); if (!row?.komodo_name) { return { ok: false, reason: 'no-name', detail: `server has no komodo_name: ${serverName}` }; } const type = row.komodo_type ?? 'deployment'; let path: string; let body: Record; switch (type) { case 'stack': path = 'read/GetStack'; body = { stack: row.komodo_name }; break; case 'container': if (!row.komodo_server) { return { ok: false, reason: 'no-name', detail: 'container type needs komodo_server' }; } 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; } const url = `${config.komodo.address.replace(/\/+$/, '')}/${path}`; let res: Response; try { res = await komodoCall(path, body); } catch (err) { return { ok: false, reason: 'unreachable', detail: describeFetchError(err, url), url }; } // Read the body up front — we want the snippet even on non-2xx so the // UI can show what the error page actually said. const rawText = await res.text().catch(() => ''); if (!res.ok) { return { ok: false, reason: 'http-error', detail: `HTTP ${res.status} ${res.statusText || ''}`.trim(), httpStatus: res.status, snippet: truncate(rawText), url }; } let json: unknown; try { json = JSON.parse(rawText); } catch (err) { return { ok: false, reason: 'unparseable', detail: `response body is not JSON: ${(err as Error).message}`, httpStatus: res.status, snippet: truncate(rawText), url }; } const port = extractUdpPort(json); const snippet = truncate(safeJsonStringify(json)); if (port === null) { return { ok: false, reason: 'no-udp-in-response', detail: 'parsed the response but no UDP port matched any known shape', httpStatus: res.status, snippet, url }; } return { ok: true, port, snippet, url }; } /** * 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. Thin shim * over `komodoResolvePortDetailed` that swallows diagnostics — use the * detailed variant when you care why it failed. */ export async function komodoResolveGamePort(serverName: string): Promise { const result = await komodoResolvePortDetailed(serverName); return result.ok ? result.port : null; } // ---------- Health probe ---------- export type KomodoHealthResult = | { ok: true; state: string } | { ok: false; reason: string; snippet?: string }; // Shape-agnostic scan for the container's running-state. Walks the entire // payload looking for anything that resembles a state field. Covered shapes: // - Docker inspect: { State: { Status: "running", Running: true, Health: { Status: "healthy" } } } // - Komodo GetDeployment: top-level or nested `state`, `Status`, `container_state` // - Komodo GetDeploymentStatus: { state: "Running" } // - Anything with `Running: true` at any depth // When it can't find any of these, callers get the raw JSON in the error so // we can spot which field to add here. const STATE_KEYS = new Set([ 'state', 'State', 'status', 'Status', 'container_state', 'containerState', 'deployment_state', 'deploymentState', 'docker_status' ]); function extractContainerState(payload: unknown): string | null { const seen = new Set(); let found: string | null = null; const walk = (v: unknown): void => { if (found || v === null || typeof v !== 'object' || seen.has(v)) return; seen.add(v); if (Array.isArray(v)) { for (const item of v) walk(item); return; } const obj = v as Record; // Docker inspect nested: { State: { Status, Running, Health: { Status } } } const stateObj = obj['State']; if (stateObj && typeof stateObj === 'object') { const so = stateObj as Record; const status = so['Status']; const running = so['Running']; const health = so['Health']; if (typeof status === 'string') { found = status; return; } if (running === true) { found = 'running'; return; } if (health && typeof health === 'object') { const hs = (health as Record)['Status']; if (typeof hs === 'string') { found = hs; return; } } } // Flat string-valued state under any of the recognized keys. for (const key of STATE_KEYS) { const val = obj[key]; if (typeof val === 'string' && val.length > 0) { found = val; return; } } for (const val of Object.values(obj)) walk(val); }; walk(payload); return found; } const HEALTHY_STATES = new Set(['running', 'healthy', 'ok', 'started', 'up']); async function fetchJsonWithSnippet( path: string, body: Record ): Promise<{ ok: true; json: unknown; snippet: string } | { ok: false; reason: string; snippet?: string }> { const url = `${config.komodo.address.replace(/\/+$/, '')}/${path}`; let res: Response; try { res = await komodoCall(path, body); } catch (err) { return { ok: false, reason: describeFetchError(err, url) }; } const text = await res.text().catch(() => ''); if (!res.ok) { return { ok: false, reason: `HTTP ${res.status}`, snippet: truncate(text) }; } let json: unknown; try { json = JSON.parse(text); } catch (err) { return { ok: false, reason: `unparseable json: ${(err as Error).message}`, snippet: truncate(text) }; } return { ok: true, json, snippet: truncate(safeJsonStringify(json)) }; } /** * Ask Komodo whether the server's container is currently running. Used by the * sweep during the health phase (post-restart) and by the beta runner. For * deployment-type servers we hit the container-inspect endpoint first because * GetDeployment returns config, not runtime state — falling back only when * the container endpoint isn't available. */ export async function komodoInspectHealthy(serverName: string): Promise { if (!komodoConfigured()) return { ok: false, reason: 'komodo not configured' }; const row = getServer(serverName); if (!row?.komodo_name) return { ok: false, reason: 'no komodo_name for server' }; const type = row.komodo_type ?? 'deployment'; const attempts: Array<{ path: string; body: Record }> = []; switch (type) { case 'stack': attempts.push({ path: 'read/GetStack', body: { stack: row.komodo_name } }); break; case 'container': if (!row.komodo_server) return { ok: false, reason: 'container type needs komodo_server' }; attempts.push({ path: 'read/InspectContainer', body: { server: row.komodo_server, container: row.komodo_name } }); break; case 'deployment': default: // Container-specific endpoints first — they return docker-inspect // shape with State.Status. GetDeployment is the last-resort // config-only fallback. attempts.push({ path: 'read/GetDeploymentContainer', body: { deployment: row.komodo_name } }); attempts.push({ path: 'read/InspectDeploymentContainer', body: { deployment: row.komodo_name } }); attempts.push({ path: 'read/GetDeploymentStatus', body: { deployment: row.komodo_name } }); attempts.push({ path: 'read/GetDeployment', body: { deployment: row.komodo_name } }); break; } let lastSnippet: string | undefined; for (const { path, body } of attempts) { const r = await fetchJsonWithSnippet(path, body); if (!r.ok) { lastSnippet = r.snippet ?? lastSnippet; // Try the next endpoint on 404s or unknown-method-shaped errors; a // real error (auth, timeout) will fail every attempt anyway. continue; } const state = extractContainerState(r.json); if (state) { const healthy = HEALTHY_STATES.has(state.toLowerCase()); if (!healthy) return { ok: false, reason: `container state: ${state}`, snippet: r.snippet }; return { ok: true, state }; } lastSnippet = r.snippet; } return { ok: false, reason: 'no container state found in any known Komodo response shape', snippet: lastSnippet }; } // ---------- Extractor ---------- // Property names Komodo/Docker use for the collection of port mappings. const PORT_ARRAY_KEYS = new Set(['ports', 'Ports', 'port_bindings', 'PortBindings']); // Value field names for the host-facing port on an object entry. const HOST_PORT_KEYS = ['published', 'PublishedPort', 'public_port', 'PublicPort', 'HostPort', 'hostPort', 'host_port']; interface PortsCandidate { arr: unknown[]; /** True when the array was reached via a `ports`-flavored key — preferred. */ preferred: boolean; } /** * Walks a Komodo response looking for a UDP port. Handles the shapes we've * seen in the wild: * * docker-compose object: [{ protocol: "udp", published: 4976, target: 28960 }] * docker-compose caps: [{ Protocol: "udp", PublishedPort: 4976 }] * docker-run string: ["4976:28960/udp", "3074:3074/tcp"] * host-network string: ["28962/udp"] * docker-inspect object: { "28960/udp": [{ "HostIp": "0.0.0.0", "HostPort": "28962" }] } * * Arrays reached via a `ports`/`Ports`/`port_bindings`/`PortBindings` key * are preferred over arrays discovered elsewhere in the tree, so an * unrelated array (e.g. tags, labels) can't shadow a real port list. */ export function extractUdpPort(payload: unknown): number | null { const candidates: PortsCandidate[] = []; // Objects whose keys encode `/` — a la docker inspect. const containerKeyed: Record[] = []; const seen = new Set(); const walk = (v: unknown, viaPortsKey: boolean): 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, false); candidates.push({ arr: v, preferred: viaPortsKey }); return; } const obj = v as Record; // Detect docker-inspect container-keyed objects: any key like `28960/udp`. for (const k of Object.keys(obj)) { if (/^\d+\/(udp|tcp)$/i.test(k)) { containerKeyed.push(obj); break; } } for (const [key, val] of Object.entries(obj)) { // Komodo's GetDeployment renders `config.ports` as a single // newline-delimited string ("28963:28963/udp\n28963:28963/tcp"). // Synthesize an array from it and treat as a preferred candidate. if (PORT_ARRAY_KEYS.has(key) && typeof val === 'string' && val.trim()) { const parts = val .split(/[\r\n,;]+/) .map((s) => s.trim()) .filter(Boolean); if (parts.length > 0) { candidates.push({ arr: parts, preferred: true }); } } walk(val, PORT_ARRAY_KEYS.has(key)); } }; walk(payload, false); // Sort so preferred `ports`-keyed arrays win over accidental hits. candidates.sort((a, b) => (a.preferred === b.preferred ? 0 : a.preferred ? -1 : 1)); // Try object-entry shape first — most Komodo/Docker responses use this. for (const { arr } of candidates) { for (const entry of arr) { if (entry && typeof entry === 'object' && !Array.isArray(entry)) { const e = entry as Record; const proto = String(e.protocol ?? e.Protocol ?? e.type ?? e.Type ?? '').toLowerCase(); if (proto !== 'udp') continue; for (const field of HOST_PORT_KEYS) { const raw = e[field]; if (raw === undefined || raw === null) continue; const num = typeof raw === 'string' ? Number.parseInt(raw, 10) : Number(raw); if (Number.isFinite(num) && num > 0) return num; } } } } // Then string shapes: "host:container/udp" or "container/udp" (host-net). for (const { arr } of candidates) { for (const entry of arr) { if (typeof entry !== 'string') continue; const hostMapped = /^(\d+):\d+\/udp$/i.exec(entry); if (hostMapped) return Number.parseInt(hostMapped[1], 10); const hostNet = /^(\d+)\/udp$/i.exec(entry); if (hostNet) return Number.parseInt(hostNet[1], 10); } } // Finally, docker-inspect container-keyed shape: // { "28960/udp": [{ HostIp, HostPort }] } for (const obj of containerKeyed) { for (const [key, val] of Object.entries(obj)) { if (!/^\d+\/udp$/i.test(key)) continue; if (!Array.isArray(val)) continue; for (const binding of val) { if (!binding || typeof binding !== 'object') continue; const b = binding as Record; for (const field of HOST_PORT_KEYS) { const raw = b[field]; if (raw === undefined || raw === null) continue; const num = typeof raw === 'string' ? Number.parseInt(raw, 10) : Number(raw); if (Number.isFinite(num) && num > 0) return num; } } } } return null; }