Surface raw RCON reply bytes + Probe RCON button

"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 <details> 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 11:12:50 -04:00
co-authored by Claude Opus 4.7
parent a5b9733bd0
commit 8affb11b57
11 changed files with 207 additions and 18 deletions
+2 -1
View File
@@ -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 {
+1
View File
@@ -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'))
);
+3 -1
View File
@@ -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);
+51 -7
View File
@@ -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<RconStatusResult
ok: false,
endpoint: ep,
reason: 'bad-reply',
detail: 'reply missing 0xff prefix — wrong port or non-Plutonium listener?'
detail: `reply missing 0xff prefix (${send.buf.length} bytes) — wrong port or non-Plutonium listener?`,
rawReply: formatRawReply(send.buf)
};
}
const info = parseInfoLine(text);
const players = parsePlayerCount(text);
if (players === null) {
// Reply arrived but neither the print separator nor an info line
// showed up — usually a wrong-password `print` body ("Bad rcon…").
const detail = text.trim().startsWith('print') && /bad\s+rcon/i.test(text)
? 'wrong RCON password'
: `unparseable reply (${text.length} bytes)`;
return { ok: false, endpoint: ep, reason: 'bad-reply', detail };
// showed up. Common wrong-password wordings vary across Plutonium
// builds ("Bad rcon_password", "Invalid password", "Bad password").
const trimmed = text.trim();
const looksLikePrint = trimmed.startsWith('print');
const suggestsPassword = /(bad|invalid|wrong).{0,20}(rcon|password|pass)/i.test(text);
let detail: string;
if (looksLikePrint && suggestsPassword) {
detail = 'wrong RCON password';
} else if (looksLikePrint) {
// print body but we couldn't classify — show the first line
const firstLine = trimmed.split(/\r?\n/)[1]?.trim() ?? '';
detail = firstLine
? `print reply not recognized: "${firstLine.slice(0, 100)}"`
: `unparseable print reply (${text.length} bytes)`;
} else {
detail = `unparseable reply (${send.buf.length} bytes) — see snippet below`;
}
return { ok: false, endpoint: ep, reason: 'bad-reply', detail, rawReply: formatRawReply(send.buf) };
}
return {
ok: true,
+1
View File
@@ -17,6 +17,7 @@ export interface ServerStatusEntry {
lastError: string | null;
endpoint: RconEndpoint | null;
reason: RconFailure | null;
replySnippet: string | null;
}
export type PipelineEvent =
+9 -3
View File
@@ -49,7 +49,8 @@ async function fanoutStatus(): Promise<void> {
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<void> {
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<void> {
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<void> {
lastSeenAt: srv.last_seen_at,
lastError: encoded,
endpoint: result.endpoint,
reason: result.reason
reason: result.reason,
replySnippet: snippet
};
}
}
+1
View File
@@ -28,6 +28,7 @@ export interface ServerStatus {
lastError: string | null;
endpoint: RconEndpoint | null;
reason: RconFailure | null;
replySnippet: string | null;
}
export const statusByServer = writable<Map<string, ServerStatus>>(new Map());
+1
View File
@@ -19,6 +19,7 @@ export async function GET() {
...s,
roster: safeJson<RconPlayer[]>(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,
@@ -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);
};
+72 -5
View File
@@ -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<Integrations | null>(null);
let history = $state<DeploymentRow[]>([]);
let name = $derived($page.params.name ?? '');
let komodoProbe = $state<KomodoProbeResult | null>(null);
let probing = $state(false);
let netDiagnosis = $state<NetDiagnosis | null>(null);
let diagnosing = $state(false);
let rconProbe = $state<RconProbeResult | null>(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<RconFailure | null>(liveReason ?? decoded?.reason ?? null);
let errorDetail = $derived(decoded?.detail ?? effectiveErr ?? '');
let effectiveSnippet = $derived<string | null>(
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<KomodoProbeResult | null>(null);
let probing = $state(false);
let netDiagnosis = $state<NetDiagnosis | null>(null);
let diagnosing = $state(false);
async function probeKomodo(): Promise<void> {
if (probing || !name) return;
probing = true;
@@ -164,6 +183,27 @@
diagnosing = false;
}
}
async function probeRcon(): Promise<void> {
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;
}
}
</script>
{#if server}
@@ -223,6 +263,33 @@
</div>
{/if}
{#if effectiveSnippet}
<details class="snippet-details" open={effectiveReason === 'bad-reply'}>
<summary>raw reply ({effectiveSnippet.split('\n').length - 1} lines)</summary>
<pre class="snippet">{effectiveSnippet}</pre>
</details>
{/if}
{#if effectiveEndpoint}
<div class="probe-row">
<button class="probe-btn" onclick={probeRcon} disabled={probingRcon}>
{probingRcon ? 'probing rcon…' : 'probe rcon'}
</button>
<span class="probe-hint">Send a fresh RCON getstatus to {effectiveEndpoint.host}:{effectiveEndpoint.port} now.</span>
</div>
{/if}
{#if rconProbe?.ok}
<div class="probe-result probe-ok">
<span class="probe-tag ok">connected</span>
<span class="probe-detail">
{rconProbe.status?.players ?? '?'} players
{#if rconProbe.status?.map} · map <strong>{rconProbe.status.map}</strong>{/if}
{#if rconProbe.status?.gametype} · {rconProbe.status.gametype}{/if}
</span>
</div>
{/if}
{#if !effectiveEndpoint && integrations.komodo}
<div class="probe-row">
<button class="probe-btn" onclick={probeKomodo} disabled={probing}>