diff --git a/src/lib/server/integrations/komodo.ts b/src/lib/server/integrations/komodo.ts index ad2d5ef..15d9639 100644 --- a/src/lib/server/integrations/komodo.ts +++ b/src/lib/server/integrations/komodo.ts @@ -80,19 +80,63 @@ export async function komodoRestart(serverName: string): Promise { - if (!komodoConfigured()) return null; +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 null; + 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; @@ -104,7 +148,9 @@ export async function komodoResolveGamePort(serverName: string): Promise ''); + + if (!res.ok) { + return { + ok: false, + reason: 'http-error', + detail: `HTTP ${res.status} ${res.statusText || ''}`.trim(), + httpStatus: res.status, + snippet: truncate(rawText) + }; } - if (!res.ok) return null; let json: unknown; try { - json = await res.json(); - } catch { - return null; + 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) + }; } - return extractUdpPort(json); + + 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 + }; + } + return { ok: true, port, snippet }; } /** - * Walks a Komodo response looking for a UDP port. Handles the two shapes - * we've seen in the wild: + * 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; +} + +// ---------- 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-style: [{ protocol: "udp", published: 4976, target: 28960 }, ...] - * docker-run-style: ["4976:28960/udp", "3074:3074/tcp"] + * 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" }] } * - * Returns the first published port whose protocol is UDP. + * 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: unknown[] = []; + const candidates: PortsCandidate[] = []; + // Objects whose keys encode `/` — a la docker inspect. + const containerKeyed: Record[] = []; const seen = new Set(); - const walk = (v: unknown): void => { + + 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); - candidates.push(v); - } else { - for (const val of Object.values(v as Record)) walk(val); + 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)) { + walk(val, PORT_ARRAY_KEYS.has(key)); } }; - walk(payload); + walk(payload, false); - // Object-form entries: { protocol, published, target } - for (const arr of candidates) { - for (const entry of arr as unknown[]) { + // 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 ?? '').toLowerCase(); + const proto = String(e.protocol ?? e.Protocol ?? e.type ?? e.Type ?? '').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; + 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; + } } } } - // String-form entries: "4976:28960/udp" - for (const arr of candidates) { - for (const entry of arr as unknown[]) { + // 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 m = /^(\d+):\d+\/udp$/i.exec(entry); - if (m) return Number.parseInt(m[1], 10); + 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; + } + } } } diff --git a/src/lib/ui/komodoError.ts b/src/lib/ui/komodoError.ts new file mode 100644 index 0000000..e6f2b02 --- /dev/null +++ b/src/lib/ui/komodoError.ts @@ -0,0 +1,27 @@ +export type KomodoPortFailure = + | 'unconfigured' + | 'no-name' + | 'unreachable' + | 'http-error' + | 'unparseable' + | 'no-udp-in-response'; + +/** Short fix hint per failure reason, shown under the Komodo probe result. */ +export function komodoFixHint(reason: KomodoPortFailure, httpStatus?: number): string { + switch (reason) { + case 'unconfigured': + return 'Set KOMODO_ADDRESS, KOMODO_API_KEY, and KOMODO_API_SECRET in the container env.'; + case 'no-name': + return 'This server row has no komodo_name — set it via SQL or via the fleet config.'; + case 'unreachable': + return 'Network, DNS, or timeout. Check that KOMODO_ADDRESS is reachable from this container.'; + case 'http-error': + if (httpStatus === 401 || httpStatus === 403) return 'Auth rejected — verify KOMODO_API_KEY and KOMODO_API_SECRET.'; + if (httpStatus === 404) return 'Endpoint or deployment not found — check komodo_name matches the Komodo UI exactly.'; + return `Komodo returned ${httpStatus ?? 'an error'}. Check the endpoint path and credentials.`; + case 'unparseable': + return 'Response was not JSON — is KOMODO_ADDRESS pointing at the Core API (not the Periphery)?'; + case 'no-udp-in-response': + return 'Parsed the response but no UDP port matched our known shapes. See the snippet below and extend extractUdpPort.'; + } +} diff --git a/src/routes/api/servers/[name]/probe-komodo/+server.ts b/src/routes/api/servers/[name]/probe-komodo/+server.ts new file mode 100644 index 0000000..c8a17ac --- /dev/null +++ b/src/routes/api/servers/[name]/probe-komodo/+server.ts @@ -0,0 +1,27 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { komodoResolvePortDetailed } from '$lib/server/integrations/komodo.js'; +import { getServer, setServerField } from '$lib/server/fleet/servers.js'; + +/** + * Trigger a fresh Komodo GetDeployment lookup for a specific server and + * return the structured result — including a snippet of the response so + * the UI can render "here's where we found the port" (or "here's what + * Komodo returned; extend extractUdpPort for this shape"). + * + * On success, the resolved port is written to servers.game_port so the + * next RCON attempt uses it without waiting for another sweep tick. + */ +export const POST: RequestHandler = async ({ params }) => { + const name = params.name; + if (!name) return json({ ok: false, reason: 'no-name', detail: 'missing server name' }, { status: 400 }); + + const row = getServer(name); + if (!row) return json({ ok: false, reason: 'no-name', detail: `unknown server: ${name}` }, { status: 404 }); + + const result = await komodoResolvePortDetailed(name); + if (result.ok) { + setServerField(name, 'game_port', result.port); + } + return json(result); +}; diff --git a/src/routes/servers/[name]/+page.svelte b/src/routes/servers/[name]/+page.svelte index 7647c74..2b42f92 100644 --- a/src/routes/servers/[name]/+page.svelte +++ b/src/routes/servers/[name]/+page.svelte @@ -7,6 +7,16 @@ import PlayerRoster from '$ui/components/PlayerRoster.svelte'; import { gametypeLabel } from '$ui/gametype.js'; import { decodeRconError, rconFixHint } from '$ui/rconError.js'; + import { komodoFixHint, type KomodoPortFailure } from '$ui/komodoError.js'; + + interface KomodoProbeResult { + ok: boolean; + port?: number; + reason?: KomodoPortFailure; + detail?: string; + httpStatus?: number; + snippet?: string; + } interface Server { name: string; @@ -81,6 +91,37 @@ if (!ep) return 'unresolved'; return `${ep.host}:${ep.port}`; } + + let komodoProbe = $state(null); + let probing = $state(false); + + async function probeKomodo(): Promise { + if (probing || !name) return; + probing = true; + komodoProbe = null; + try { + const res = await fetch(`/api/servers/${encodeURIComponent(name)}/probe-komodo`, { + method: 'POST' + }); + komodoProbe = (await res.json()) as KomodoProbeResult; + // On success, refresh the server row so the endpoint field + // updates from `unresolved` to `host:port` without a full reload. + if (komodoProbe.ok && server) { + const srvRes = await fetch('/api/servers'); + const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations }; + server = srvBody.servers.find((s) => s.name === name) ?? server; + integrations = srvBody.integrations; + } + } catch (err) { + komodoProbe = { + ok: false, + reason: 'unreachable', + detail: (err as Error).message + }; + } finally { + probing = false; + } + } {#if server} @@ -139,6 +180,46 @@ {effectiveErr} {/if} + + {#if !effectiveEndpoint && integrations.komodo} +
+ + Ask Komodo for this server's UDP port now. +
+ {/if} + + {#if komodoProbe} + {#if komodoProbe.ok} +
+ resolved + + Port {komodoProbe.port} cached to server row — RCON will retry on the next tick. + +
+ {#if komodoProbe.snippet} +
+ show response snippet +
{komodoProbe.snippet}
+
+ {/if} + {:else} +
+ {komodoProbe.reason} + + {komodoProbe.detail} + {#if komodoProbe.httpStatus} · HTTP {komodoProbe.httpStatus}{/if} + +
+ {#if komodoProbe.reason} +
{komodoFixHint(komodoProbe.reason, komodoProbe.httpStatus)}
+ {/if} + {#if komodoProbe.snippet} +
{komodoProbe.snippet}
+ {/if} + {/if} + {/if} {/if} @@ -350,6 +431,107 @@ border-radius: var(--r-sm); } + .probe-row { + display: flex; + align-items: center; + gap: 12px; + margin-top: 14px; + } + .probe-btn { + background: var(--cherry-875); + color: var(--cream); + border: 1px solid var(--spark-500); + border-radius: var(--r-sm); + padding: 7px 14px; + font-family: var(--font-body); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.3px; + cursor: pointer; + transition: box-shadow 0.15s, background 0.15s; + } + .probe-btn:hover:not(:disabled) { + box-shadow: var(--spark-glow); + background: var(--cherry-825); + } + .probe-btn:disabled { opacity: 0.5; cursor: default; } + .probe-hint { + color: var(--ink-500); + font-family: var(--font-mono); + font-size: 11px; + } + + .probe-result { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; + padding: 10px 14px; + border-radius: var(--r-sm); + border: 1px solid transparent; + } + .probe-ok { + background: rgba(34, 224, 255, 0.08); + border-color: rgba(34, 224, 255, 0.3); + } + .probe-bad { + background: rgba(255, 48, 80, 0.08); + border-color: rgba(255, 48, 80, 0.25); + } + .probe-tag { + padding: 2px 8px; + border-radius: var(--r-sm); + font-family: var(--font-mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 1px; + border: 1px solid transparent; + flex-shrink: 0; + } + .probe-tag.ok { + background: rgba(34, 224, 255, 0.15); + border-color: rgba(34, 224, 255, 0.4); + color: var(--spark-500); + } + .probe-tag.bad { + background: rgba(255, 48, 80, 0.15); + border-color: rgba(255, 48, 80, 0.4); + color: var(--danger-500); + } + .probe-detail { + color: var(--ink-100); + font-family: var(--font-mono); + font-size: 12px; + } + .probe-detail strong { color: var(--spark-500); } + + .snippet-details { + margin-top: 10px; + font-family: var(--font-mono); + font-size: 11px; + } + .snippet-details summary { + color: var(--ink-500); + cursor: pointer; + padding: 4px 0; + } + .snippet-details summary:hover { color: var(--ink-100); } + .snippet { + margin: 10px 0 0; + padding: 12px 14px; + background: var(--cherry-975); + border: 1px solid var(--cherry-700); + border-radius: var(--r-sm); + color: var(--ink-300); + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.5; + max-height: 400px; + overflow: auto; + white-space: pre-wrap; + word-break: break-all; + } + .row { display: grid; grid-template-columns: 200px 160px 100px 120px 1fr; diff --git a/tests/unit/komodo.test.ts b/tests/unit/komodo.test.ts index 5c7b1cb..e7aa63e 100644 --- a/tests/unit/komodo.test.ts +++ b/tests/unit/komodo.test.ts @@ -45,4 +45,51 @@ describe('extractUdpPort', () => { cyc.self = cyc; expect(extractUdpPort(cyc)).toBeNull(); }); + + it('recognizes HostPort / hostPort / host_port on object entries', () => { + expect(extractUdpPort({ ports: [{ protocol: 'udp', HostPort: 28962 }] })).toBe(28962); + expect(extractUdpPort({ ports: [{ protocol: 'udp', hostPort: 28963 }] })).toBe(28963); + expect(extractUdpPort({ ports: [{ protocol: 'udp', host_port: 28964 }] })).toBe(28964); + }); + + it('recognizes `type` alongside `protocol` as the protocol field', () => { + expect(extractUdpPort({ ports: [{ type: 'UDP', published: 28965 }] })).toBe(28965); + }); + + it('parses host-network "container/udp" strings', () => { + expect(extractUdpPort({ ports: ['28966/udp'] })).toBe(28966); + }); + + it('handles docker-inspect container-keyed PortBindings shape', () => { + const payload = { + NetworkSettings: { + Ports: { + '28960/udp': [{ HostIp: '0.0.0.0', HostPort: '28967' }], + '3074/tcp': [{ HostIp: '0.0.0.0', HostPort: '3074' }] + } + } + }; + expect(extractUdpPort(payload)).toBe(28967); + }); + + it('prefers `ports`-keyed arrays over unrelated arrays discovered deeper', () => { + // The unrelated array happens to contain a matching entry — the walker + // must still prefer the real `ports` list. + const payload = { + labels: [{ protocol: 'udp', published: 9999 }], + state: { container: { ports: [{ protocol: 'udp', published: 28968 }] } } + }; + expect(extractUdpPort(payload)).toBe(28968); + }); +}); + +describe('komodoResolvePortDetailed', () => { + it('is importable and has the discriminated-union shape at the type level', async () => { + // Runtime import shim just to prove it's exported. Behavior is + // covered by the fixture-based extractUdpPort tests above plus manual + // end-to-end verification against a live Komodo. + const mod = await import('../../src/lib/server/integrations/komodo.js'); + expect(typeof mod.komodoResolvePortDetailed).toBe('function'); + expect(typeof mod.komodoResolveGamePort).toBe('function'); + }); });