From 8affb11b57c80d4936543f450b9751beee753c70 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 12 Sep 2026 11:12:50 -0400 Subject: [PATCH] Surface raw RCON reply bytes + Probe RCON button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "unparseable reply (34 bytes)" told us Plutonium answered but hid the actual bytes, forcing another guess-and-check round. rconStatusResult now emits a formatRawReply() hex + ASCII dump of the raw UDP payload on every bad-reply, persisted in a new last_reply_snippet column so it survives page loads. Wrong-password detection widens beyond the "bad rcon" phrasing to "(bad|invalid|wrong)…(rcon|password|pass)"; print-body replies that don't match get their first non-header line surfaced verbatim so at least *some* server-supplied text reaches the UI. Probe RCON button (POST /api/servers/[name]/probe-rcon) fires the same code path as the periodic sweep on demand, cuts the RCON iteration loop from 30s per attempt to sub-second, and refreshes the header/roster/ health when it succeeds. Snippet
auto-opens on bad-reply so the bytes are one glance away. migrateServers() picks up the new column automatically via the introspection path added last commit. Co-Authored-By: Claude Opus 4.7 --- src/lib/server/db/index.ts | 3 +- src/lib/server/db/schema.sql | 1 + src/lib/server/fleet/servers.ts | 4 +- src/lib/server/integrations/rcon.ts | 58 ++++++++++++-- src/lib/server/pipeline/bus.ts | 1 + src/lib/server/pipeline/worker.ts | 12 ++- src/lib/ui/stores/servers.ts | 1 + src/routes/api/servers/+server.ts | 1 + .../api/servers/[name]/probe-rcon/+server.ts | 41 ++++++++++ src/routes/servers/[name]/+page.svelte | 77 +++++++++++++++++-- tests/unit/rcon.test.ts | 26 ++++++- 11 files changed, 207 insertions(+), 18 deletions(-) create mode 100644 src/routes/api/servers/[name]/probe-rcon/+server.ts diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index ff8fc5d..79cf543 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -24,7 +24,8 @@ const SERVERS_COLUMNS: Array<{ name: string; ddl: string }> = [ { name: 'last_gametype', ddl: 'ALTER TABLE servers ADD COLUMN last_gametype TEXT' }, { name: 'last_roster', ddl: 'ALTER TABLE servers ADD COLUMN last_roster TEXT' }, { name: 'last_seen_at', ddl: 'ALTER TABLE servers ADD COLUMN last_seen_at TEXT' }, - { name: 'last_error', ddl: 'ALTER TABLE servers ADD COLUMN last_error TEXT' } + { name: 'last_error', ddl: 'ALTER TABLE servers ADD COLUMN last_error TEXT' }, + { name: 'last_reply_snippet', ddl: 'ALTER TABLE servers ADD COLUMN last_reply_snippet TEXT' } ]; function migrateServers(handle: Database.Database): void { diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index 1d42b72..2dce41f 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS servers ( last_roster TEXT, -- JSON array of RconPlayer last_seen_at TEXT, -- ISO ts of last successful RCON reply last_error TEXT, -- last RCON failure reason (cleared on success) + last_reply_snippet TEXT, -- hex+ASCII of last bad-reply UDP payload created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); diff --git a/src/lib/server/fleet/servers.ts b/src/lib/server/fleet/servers.ts index b63874c..d9f9a18 100644 --- a/src/lib/server/fleet/servers.ts +++ b/src/lib/server/fleet/servers.ts @@ -76,6 +76,7 @@ export interface ServerRow { last_roster: string | null; // JSON array of RconPlayer, or null last_seen_at: string | null; // last successful RCON reply last_error: string | null; // cleared on success + last_reply_snippet: string | null; // hex+ASCII of last bad-reply payload created_at: string; } @@ -126,7 +127,8 @@ export function setServerField( 'last_gametype', 'last_roster', 'last_seen_at', - 'last_error' + 'last_error', + 'last_reply_snippet' ]; if (!allowed.includes(field)) throw new Error(`refusing to set ${field}`); db().prepare(`UPDATE servers SET ${field} = ? WHERE name = ?`).run(value, name); diff --git a/src/lib/server/integrations/rcon.ts b/src/lib/server/integrations/rcon.ts index 7150dba..79892e5 100644 --- a/src/lib/server/integrations/rcon.ts +++ b/src/lib/server/integrations/rcon.ts @@ -231,7 +231,37 @@ export interface RconStatus { export type RconStatusResult = | { ok: true; endpoint: RconEndpoint; status: RconStatus } - | { ok: false; endpoint: RconEndpoint | null; reason: RconFailure; detail: string }; + | { + ok: false; + endpoint: RconEndpoint | null; + reason: RconFailure; + detail: string; + // Present on bad-reply: printable hex + ASCII dump of the raw UDP + // payload so the operator can see what the server actually said + // (wrong password body, unexpected dialect, etc). + rawReply?: string; + }; + +const RAW_REPLY_LIMIT = 512; + +/** + * Render a Buffer as a short hex + printable-ASCII snippet — same layout as + * `hexdump -C`, capped at RAW_REPLY_LIMIT bytes so it fits in the UI panel. + */ +export function formatRawReply(buf: Buffer): string { + const bytes = buf.subarray(0, RAW_REPLY_LIMIT); + const lines: string[] = []; + for (let i = 0; i < bytes.length; i += 16) { + const chunk = bytes.subarray(i, i + 16); + const hex = Array.from(chunk).map((b) => b.toString(16).padStart(2, '0')).join(' '); + const ascii = Array.from(chunk).map((b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : '.')).join(''); + lines.push(`${i.toString(16).padStart(4, '0')} ${hex.padEnd(47, ' ')} ${ascii}`); + } + if (buf.length > RAW_REPLY_LIMIT) { + lines.push(`… (${buf.length - RAW_REPLY_LIMIT} more bytes)`); + } + return lines.join('\n'); +} /** * Full RCON status probe with structured diagnostics. Callers get either @@ -275,18 +305,32 @@ export async function rconStatusResult(server: string): Promise { ok: false as const, endpoint: null, reason: 'send-error' as RconFailure, - detail: (err as Error).message + detail: (err as Error).message, + rawReply: undefined as string | undefined })); const now = new Date().toISOString(); @@ -62,6 +63,7 @@ async function fanoutStatus(): Promise { setServerField(srv.name, 'last_seen_at', now); setServerField(srv.name, 'last_checked_at', now); setServerField(srv.name, 'last_error', null); + setServerField(srv.name, 'last_reply_snippet', null); entries[i] = { server: srv.name, players: result.status.players, @@ -72,14 +74,17 @@ async function fanoutStatus(): Promise { lastSeenAt: now, lastError: null, endpoint: result.endpoint, - reason: null + reason: null, + replySnippet: null }; } else { // RCON unreachable — leave last_seen_at alone (health decays // to offline naturally), record the structured failure. const encoded = encodeError(result.reason, result.detail); + const snippet = result.rawReply ?? null; setServerField(srv.name, 'last_checked_at', now); setServerField(srv.name, 'last_error', encoded); + setServerField(srv.name, 'last_reply_snippet', snippet); entries[i] = { server: srv.name, players: srv.last_players, @@ -90,7 +95,8 @@ async function fanoutStatus(): Promise { lastSeenAt: srv.last_seen_at, lastError: encoded, endpoint: result.endpoint, - reason: result.reason + reason: result.reason, + replySnippet: snippet }; } } diff --git a/src/lib/ui/stores/servers.ts b/src/lib/ui/stores/servers.ts index 33e77e4..1604196 100644 --- a/src/lib/ui/stores/servers.ts +++ b/src/lib/ui/stores/servers.ts @@ -28,6 +28,7 @@ export interface ServerStatus { lastError: string | null; endpoint: RconEndpoint | null; reason: RconFailure | null; + replySnippet: string | null; } export const statusByServer = writable>(new Map()); diff --git a/src/routes/api/servers/+server.ts b/src/routes/api/servers/+server.ts index 85a998d..0c048f1 100644 --- a/src/routes/api/servers/+server.ts +++ b/src/routes/api/servers/+server.ts @@ -19,6 +19,7 @@ export async function GET() { ...s, roster: safeJson(s.last_roster), health: classifyHealth(s.last_seen_at, s.last_error), + replySnippet: s.last_reply_snippet, // Derived endpoint — same logic as endpointFor() but without asking // Komodo. If we don't have a port yet, endpoint is null. endpoint: s.game_port ? { host: s.rcon_host ?? rconHost, port: s.game_port } : null, diff --git a/src/routes/api/servers/[name]/probe-rcon/+server.ts b/src/routes/api/servers/[name]/probe-rcon/+server.ts new file mode 100644 index 0000000..b21f9e5 --- /dev/null +++ b/src/routes/api/servers/[name]/probe-rcon/+server.ts @@ -0,0 +1,41 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { rconStatusResult } from '$lib/server/integrations/rcon.js'; +import { getServer, setServerField } from '$lib/server/fleet/servers.js'; + +/** + * On-demand RCON probe for a specific server. Same code path as the periodic + * sweep but callable from the UI so operators can iterate on RCON password / + * port / firewall changes without waiting for the next 30s tick. + * + * Mirrors the persist logic in worker.ts so the DB stays consistent with + * whatever the caller sees. + */ +export const POST: RequestHandler = async ({ params }) => { + const name = params.name; + if (!name) return json({ ok: false, error: 'missing server name' }, { status: 400 }); + + const row = getServer(name); + if (!row) return json({ ok: false, error: `unknown server: ${name}` }, { status: 404 }); + + const result = await rconStatusResult(name); + const now = new Date().toISOString(); + + if (result.ok) { + setServerField(name, 'last_players', result.status.players); + setServerField(name, 'last_map', result.status.map); + setServerField(name, 'last_gametype', result.status.gametype); + setServerField(name, 'last_roster', JSON.stringify(result.status.roster)); + setServerField(name, 'last_seen_at', now); + setServerField(name, 'last_checked_at', now); + setServerField(name, 'last_error', null); + setServerField(name, 'last_reply_snippet', null); + } else { + const encoded = `${result.reason}:${result.detail}`; + setServerField(name, 'last_checked_at', now); + setServerField(name, 'last_error', encoded); + setServerField(name, 'last_reply_snippet', result.rawReply ?? null); + } + + return json(result); +}; diff --git a/src/routes/servers/[name]/+page.svelte b/src/routes/servers/[name]/+page.svelte index 092f1ea..8b40c85 100644 --- a/src/routes/servers/[name]/+page.svelte +++ b/src/routes/servers/[name]/+page.svelte @@ -42,6 +42,20 @@ error?: string; } + interface RconProbeResult { + ok: boolean; + endpoint?: { host: string; port: number } | null; + reason?: RconFailure; + detail?: string; + rawReply?: string; + status?: { + players: number; + map: string | null; + gametype: string | null; + roster: RosterPlayer[]; + }; + } + interface Server { name: string; komodo_type: string; @@ -58,6 +72,7 @@ roster: RosterPlayer[] | null; health: Health; endpoint: RconEndpoint | null; + replySnippet: string | null; held: Array<{ target: string; slug: string }>; } interface Integrations { @@ -78,6 +93,12 @@ let integrations = $state(null); let history = $state([]); let name = $derived($page.params.name ?? ''); + let komodoProbe = $state(null); + let probing = $state(false); + let netDiagnosis = $state(null); + let diagnosing = $state(false); + let rconProbe = $state(null); + let probingRcon = $state(false); onMount(async () => { const [srvRes, depRes] = await Promise.all([fetch('/api/servers'), fetch('/api/deployments')]); @@ -101,6 +122,9 @@ let decoded = $derived(decodeRconError(effectiveErr)); let effectiveReason = $derived(liveReason ?? decoded?.reason ?? null); let errorDetail = $derived(decoded?.detail ?? effectiveErr ?? ''); + let effectiveSnippet = $derived( + rconProbe?.rawReply ?? live?.replySnippet ?? server?.replySnippet ?? null + ); function ago(iso: string | null): string { if (!iso) return 'never'; @@ -116,11 +140,6 @@ return `${ep.host}:${ep.port}`; } - let komodoProbe = $state(null); - let probing = $state(false); - let netDiagnosis = $state(null); - let diagnosing = $state(false); - async function probeKomodo(): Promise { if (probing || !name) return; probing = true; @@ -164,6 +183,27 @@ diagnosing = false; } } + + async function probeRcon(): Promise { + if (probingRcon || !name) return; + probingRcon = true; + rconProbe = null; + try { + const res = await fetch(`/api/servers/${encodeURIComponent(name)}/probe-rcon`, { + method: 'POST' + }); + rconProbe = (await res.json()) as RconProbeResult; + // Refresh the server row so the header, roster, and health decay + // reflect what the probe just wrote. + 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; + } catch (err) { + rconProbe = { ok: false, reason: 'send-error', detail: (err as Error).message }; + } finally { + probingRcon = false; + } + } {#if server} @@ -223,6 +263,33 @@ {/if} + {#if effectiveSnippet} +
+ raw reply ({effectiveSnippet.split('\n').length - 1} lines) +
{effectiveSnippet}
+
+ {/if} + + {#if effectiveEndpoint} +
+ + Send a fresh RCON getstatus to {effectiveEndpoint.host}:{effectiveEndpoint.port} now. +
+ {/if} + + {#if rconProbe?.ok} +
+ connected + + {rconProbe.status?.players ?? '?'} players + {#if rconProbe.status?.map} · map {rconProbe.status.map}{/if} + {#if rconProbe.status?.gametype} · {rconProbe.status.gametype}{/if} + +
+ {/if} + {#if !effectiveEndpoint && integrations.komodo}