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:
@@ -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 {
|
||||
|
||||
@@ -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'))
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface ServerStatusEntry {
|
||||
lastError: string | null;
|
||||
endpoint: RconEndpoint | null;
|
||||
reason: RconFailure | null;
|
||||
replySnippet: string | null;
|
||||
}
|
||||
|
||||
export type PipelineEvent =
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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}>
|
||||
|
||||
+25
-1
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseInfoLine, parsePlayerCount, parsePlayerRoster } from '../../src/lib/server/integrations/rcon.js';
|
||||
import {
|
||||
parseInfoLine,
|
||||
parsePlayerCount,
|
||||
parsePlayerRoster,
|
||||
formatRawReply
|
||||
} from '../../src/lib/server/integrations/rcon.js';
|
||||
|
||||
describe('parsePlayerCount', () => {
|
||||
it('counts rows after --- --- separator (status dialect)', () => {
|
||||
@@ -113,3 +118,22 @@ describe('parsePlayerRoster', () => {
|
||||
expect(parsePlayerRoster('nonsense')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRawReply', () => {
|
||||
it('renders a small buffer as hex + printable ASCII with an offset', () => {
|
||||
const buf = Buffer.from([0xff, 0xff, 0xff, 0xff, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x0a]);
|
||||
const out = formatRawReply(buf);
|
||||
expect(out).toContain('0000');
|
||||
expect(out).toContain('ff ff ff ff 70 72 69 6e 74 0a');
|
||||
// Non-printable prefix bytes render as `.`; the printable tail spells "print".
|
||||
expect(out).toContain('....print.');
|
||||
});
|
||||
|
||||
it('splits into 16-byte rows and truncates past the limit', () => {
|
||||
const buf = Buffer.alloc(600, 0x41); // "A" repeated
|
||||
const out = formatRawReply(buf);
|
||||
const lines = out.split('\n');
|
||||
expect(lines.length).toBeGreaterThan(1);
|
||||
expect(lines[lines.length - 1]).toMatch(/more bytes/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user