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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user