Use status for the RCON probe (Plutonium doesn't wrap-recognize getstatus)

The hex dump surfaced Plutonium's actual reply:
  ff ff ff ff 70 72 69 6e 74 0a  ....print.
  55 6e 6b 6e 6f 77 6e 20 63 6f  Unknown co
  6d 6d 61 6e 64 20 22 67 65 74  mmand "get
  73 74 61 74 75 73 22 0a        status".

Auth was fine — Plutonium just doesn't recognize `getstatus` as an rcon
subcommand. In Q3-derived engines, `getstatus` is an anonymous query
(no rcon wrapper); we were sending `rcon <password> getstatus`, which
made Plutonium treat it as an unknown subcommand.

Switch rconStatusResult to use `status`, same command rconGetPlayerCount
has been using successfully all along. The response is a `print` body
with a `key: value` header (`map: mp_kino\n...`) followed by the
scoreboard, not the `\info\` line the statusResponse dialect uses.

New parseStatusHeader extracts key:value dvars from the header, stopping
at the `--- ---` separator (or the first player row) so guids and names
can't leak into the metadata. rconStatusResult now merges header values
with the statusResponse info line so both dialects produce map/gametype
in the RconStatus shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 11:19:26 -04:00
co-authored by Claude Opus 4.7
parent 8affb11b57
commit 57746eef19
2 changed files with 72 additions and 5 deletions
+31 -5
View File
@@ -157,6 +157,25 @@ export function parseInfoLine(text: string): Record<string, string> {
return out;
}
/**
* Parse `key: value` header lines from a `status` (print) response, stopping
* at the scoreboard `--- ---` separator so player rows (which can contain
* colons in guids or names) don't leak into the metadata. Plutonium T5's
* `status` puts things like `map: mp_kino` and (on some builds) gametype
* in the header.
*/
export function parseStatusHeader(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (/^-+\s+-+/.test(line)) break; // scoreboard row — stop
if (/^\d+\s+/.test(line)) break; // player row that snuck in
const m = /^([a-z][a-z0-9_]*)\s*:\s*(.+)$/i.exec(line);
if (m) out[m[1].toLowerCase()] = m[2].trim();
}
return out;
}
export interface RconPlayer {
num: number | null; // client slot — print dialect only
score: number;
@@ -292,9 +311,13 @@ export async function rconStatusResult(server: string): Promise<RconStatusResult
}
const ep = res.endpoint;
// `getstatus` is anonymous on most CoD builds but Plutonium requires the
// authenticated rcon route for reliable results, so we ask via rcon.
const send = await sendRcon(ep.host, ep.port, 'getstatus');
// Plutonium T5 doesn't recognize `getstatus` as an rcon subcommand
// ("Unknown command"), so we use `status` — a print-body response with
// a header table plus one row per player. Q3-derived engines that
// *do* answer statusResponse (via anonymous `getstatus` query) would
// give richer info-line dvars, but every engine we care about supports
// `status` under the rcon wrapper.
const send = await sendRcon(ep.host, ep.port, 'status');
if (!send.ok) {
return { ok: false, endpoint: ep, reason: send.reason, detail: send.detail };
}
@@ -332,13 +355,16 @@ export async function rconStatusResult(server: string): Promise<RconStatusResult
}
return { ok: false, endpoint: ep, reason: 'bad-reply', detail, rawReply: formatRawReply(send.buf) };
}
const header = parseStatusHeader(text);
return {
ok: true,
endpoint: ep,
status: {
// Prefer statusResponse `\info\` line values when present, fall
// back to print-body `key: value` header lines otherwise.
players,
map: info.mapname ?? info.sv_mapname ?? null,
gametype: info.g_gametype ?? null,
map: info.mapname ?? info.sv_mapname ?? header.map ?? header.mapname ?? header.sv_mapname ?? null,
gametype: info.g_gametype ?? header.g_gametype ?? header.gametype ?? null,
roster: parsePlayerRoster(text)
}
};
+41
View File
@@ -3,6 +3,7 @@ import {
parseInfoLine,
parsePlayerCount,
parsePlayerRoster,
parseStatusHeader,
formatRawReply
} from '../../src/lib/server/integrations/rcon.js';
@@ -119,6 +120,46 @@ describe('parsePlayerRoster', () => {
});
});
describe('parseStatusHeader', () => {
it('extracts key:value header lines from a Plutonium status body', () => {
const body = [
'print',
'map: mp_kino',
'num score ping guid name',
'--- ----- ---- -------- ----',
' 0 12 45 abc123def456 joe'
].join('\n');
const h = parseStatusHeader(body);
expect(h.map).toBe('mp_kino');
// Header keys stop at the scoreboard — guids/names must not leak in.
expect(h.abc123def456).toBeUndefined();
});
it('handles builds that include gametype or other dvars in the header', () => {
const body = [
'print',
'map: mp_ascension',
'gametype: zclassic',
'sv_maxclients: 4',
'--- --- ---',
''
].join('\n');
const h = parseStatusHeader(body);
expect(h.map).toBe('mp_ascension');
expect(h.gametype).toBe('zclassic');
expect(h.sv_maxclients).toBe('4');
});
it('returns empty object for a statusResponse (no colon header)', () => {
const body = [
'statusResponse',
'\\mapname\\mp_kino\\g_gametype\\zclassic',
'10 45 "joe"'
].join('\n');
expect(parseStatusHeader(body)).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]);