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:
@@ -157,6 +157,25 @@ export function parseInfoLine(text: string): Record<string, string> {
|
|||||||
return out;
|
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 {
|
export interface RconPlayer {
|
||||||
num: number | null; // client slot — print dialect only
|
num: number | null; // client slot — print dialect only
|
||||||
score: number;
|
score: number;
|
||||||
@@ -292,9 +311,13 @@ export async function rconStatusResult(server: string): Promise<RconStatusResult
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ep = res.endpoint;
|
const ep = res.endpoint;
|
||||||
// `getstatus` is anonymous on most CoD builds but Plutonium requires the
|
// Plutonium T5 doesn't recognize `getstatus` as an rcon subcommand
|
||||||
// authenticated rcon route for reliable results, so we ask via rcon.
|
// ("Unknown command"), so we use `status` — a print-body response with
|
||||||
const send = await sendRcon(ep.host, ep.port, 'getstatus');
|
// 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) {
|
if (!send.ok) {
|
||||||
return { ok: false, endpoint: ep, reason: send.reason, detail: send.detail };
|
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) };
|
return { ok: false, endpoint: ep, reason: 'bad-reply', detail, rawReply: formatRawReply(send.buf) };
|
||||||
}
|
}
|
||||||
|
const header = parseStatusHeader(text);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
endpoint: ep,
|
endpoint: ep,
|
||||||
status: {
|
status: {
|
||||||
|
// Prefer statusResponse `\info\` line values when present, fall
|
||||||
|
// back to print-body `key: value` header lines otherwise.
|
||||||
players,
|
players,
|
||||||
map: info.mapname ?? info.sv_mapname ?? null,
|
map: info.mapname ?? info.sv_mapname ?? header.map ?? header.mapname ?? header.sv_mapname ?? null,
|
||||||
gametype: info.g_gametype ?? null,
|
gametype: info.g_gametype ?? header.g_gametype ?? header.gametype ?? null,
|
||||||
roster: parsePlayerRoster(text)
|
roster: parsePlayerRoster(text)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
parseInfoLine,
|
parseInfoLine,
|
||||||
parsePlayerCount,
|
parsePlayerCount,
|
||||||
parsePlayerRoster,
|
parsePlayerRoster,
|
||||||
|
parseStatusHeader,
|
||||||
formatRawReply
|
formatRawReply
|
||||||
} from '../../src/lib/server/integrations/rcon.js';
|
} 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', () => {
|
describe('formatRawReply', () => {
|
||||||
it('renders a small buffer as hex + printable ASCII with an offset', () => {
|
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 buf = Buffer.from([0xff, 0xff, 0xff, 0xff, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x0a]);
|
||||||
|
|||||||
Reference in New Issue
Block a user