Protect RCON diagnostics and redact player addresses (Refs #61)
This commit is contained in:
@@ -40,7 +40,8 @@ export function routePermission(routeId: string | null, method: string): RoutePe
|
||||
if (routeId === '/configs/[id]' || routeId === '/api/configs/[id]' || routeId === '/api/configs/inspect'
|
||||
|| routeId === '/api/campaigns/[id]/file/[fileId]' || routeId === '/api/deployments/[id]/file/[fileId]'
|
||||
|| routeId === '/api/betas/[id]/revisions/[revision]/files/[scriptId]'
|
||||
|| routeId === '/api/servers/[name]/logs/[file]' || routeId === '/api/servers/[name]/telemetry-requests') return 'operator';
|
||||
|| routeId === '/api/servers/[name]/logs/[file]' || routeId === '/api/servers/[name]/telemetry-requests'
|
||||
|| routeId === '/api/servers/[name]/rcon-diagnostics') return 'operator';
|
||||
|
||||
return reading ? 'viewer' : 'operator';
|
||||
}
|
||||
|
||||
@@ -357,6 +357,16 @@ export function db(): Database.Database {
|
||||
{ name: 'origin', ddl: "ALTER TABLE maintenance_jobs ADD COLUMN origin TEXT NOT NULL DEFAULT 'manual'" }
|
||||
]);
|
||||
migrateServers(handle);
|
||||
// Old snippets may contain player addresses, including hex-encoded bytes.
|
||||
// Discard them once; newly retained diagnostics are sanitized failure replies only.
|
||||
if (!handle.prepare("SELECT 1 FROM settings WHERE key='rcon_private_diagnostics_v1'").get()) {
|
||||
handle.transaction(() => {
|
||||
handle.exec(`UPDATE servers SET last_reply_snippet=NULL;
|
||||
UPDATE servers SET last_error='bad-reply:unparseable print reply'
|
||||
WHERE last_error LIKE 'bad-reply:print reply not recognized:%';
|
||||
INSERT INTO settings(key,value) VALUES('rcon_private_diagnostics_v1','1');`);
|
||||
})();
|
||||
}
|
||||
backfillDesiredState(handle);
|
||||
// Column additions must run BEFORE any statement that references the
|
||||
// new columns. schema.sql cannot include those references — see the
|
||||
|
||||
@@ -97,7 +97,7 @@ CREATE TABLE IF NOT EXISTS servers (
|
||||
last_seen_at TEXT, -- ISO ts of last successful RCON reply
|
||||
last_error TEXT, -- last RCON failure reason (cleared on success)
|
||||
rcon_failures INTEGER NOT NULL DEFAULT 0, -- consecutive monitoring failures, capped at two
|
||||
last_reply_snippet TEXT, -- hex+ASCII of last bad-reply UDP payload
|
||||
last_reply_snippet TEXT, -- sanitized failure diagnostics; operator-only
|
||||
-- Provisioning fields — set when Electric Cherry creates the deployment.
|
||||
game_mode TEXT, -- 'zombie' | 'mp' | 'sp' (currently zombie only)
|
||||
map_slug TEXT, -- 'cosmodrome' — short slug used in server name
|
||||
|
||||
@@ -8,7 +8,7 @@ export function successfulStatusFields(result: Extract<RconStatusResult, { ok: t
|
||||
last_checked_at: observedAt, last_seen_at: observedAt,
|
||||
last_players: result.status.players, last_map: result.status.map,
|
||||
last_gametype: result.status.gametype, last_roster: JSON.stringify(result.status.roster),
|
||||
last_reply_snippet: result.rawReply ?? null, last_error: null, rcon_failures: 0
|
||||
last_reply_snippet: null, last_error: null, rcon_failures: 0
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -49,12 +49,13 @@ export function serverObservation(server: ServerRow, evidence?: Evidence) {
|
||||
const state = (evidence ? evidence.settling : restartSettling(server.name)) ? 'restarting' : server.container_state;
|
||||
const ready = !provisioning && (!state || state === 'running' || state === 'unknown');
|
||||
const health = provisioning ? 'provisioning' : classifyHealth(server.last_seen_at, rconHealthError(rcon), state);
|
||||
return selectServerObservation({ ...server, ...rcon, rcon_status: rcon, container_state: state, health,
|
||||
// This projection is shared by viewer-visible snapshots. Never spread saved diagnostics.
|
||||
const { last_reply_snippet: _snippet, ...publicServer } = server;
|
||||
return selectServerObservation({ ...publicServer, ...rcon, rcon_status: rcon, container_state: state, health,
|
||||
status_source: null as 'telemetry' | 'rcon' | null,
|
||||
telemetry: (evidence ? evidence.telemetry : telemetryReport(server.name)) ?? undefined,
|
||||
telemetry_after: evidence ? evidence.submission : latestGameSubmission(server.name),
|
||||
last_error: ready ? server.last_error : null,
|
||||
replySnippet: ready ? server.last_reply_snippet : null,
|
||||
active_since: ready ? server.active_since : null, inactive_since: ready ? server.inactive_since : null,
|
||||
game_started_at: ready ? server.game_started_at : null, game_ended_at: ready ? server.game_ended_at : null
|
||||
}, undefined, Date.now());
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { RconStatusResult } from './rcon.js';
|
||||
|
||||
/** Redact before truncating or hex encoding, including IPv6 and mapped IPv4 endpoints. */
|
||||
export function redactRconAddresses(text: string): string {
|
||||
return text.replace(/\[[0-9a-f:.]+(?:%[\w.-]+)?\](?::\d+)?|(?<![\w:])(?:[0-9a-f]{0,4}:){2,}[0-9a-f:.]*(?:%[\w.-]+)?|\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b/gi, '[redacted]');
|
||||
}
|
||||
|
||||
/** Successful checks clear diagnostics; only sanitized failure evidence is retained. */
|
||||
export function failureReplySnippet(result: RconStatusResult): string | null {
|
||||
return !result.ok && result.rawReply ? redactRconAddresses(result.rawReply).slice(0, 4000) : null;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { getServer } from '../fleet/servers.js';
|
||||
import { latestGameSubmission } from '../fleet/lifecycle-evidence.js';
|
||||
import { parsePlutoniumRevision } from '../../plutonium.js';
|
||||
import { nativeInstance, nativeRequest } from '../backends/native.js';
|
||||
import { redactRconAddresses } from './rcon-diagnostics.js';
|
||||
|
||||
// Plutonium/CoD RCON is UDP with a 4-byte 0xff prefix. Requests look like
|
||||
// \xff\xff\xff\xffrcon <password> <command>
|
||||
@@ -306,18 +307,14 @@ export type RconStatusResult =
|
||||
ok: true;
|
||||
endpoint: RconEndpoint;
|
||||
status: RconStatus;
|
||||
// Kept on success too, so operators can compare "what we parsed"
|
||||
// vs "what the server actually sent" from the detail page.
|
||||
rawReply: string;
|
||||
rawReply?: never;
|
||||
}
|
||||
| {
|
||||
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).
|
||||
// Sanitized failure evidence, available only through operator diagnostics.
|
||||
rawReply?: string;
|
||||
};
|
||||
|
||||
@@ -340,6 +337,8 @@ async function sendStatus(endpoint: RconEndpoint): Promise<SendResult> {
|
||||
* `hexdump -C`, capped at RAW_REPLY_LIMIT bytes so it fits in the UI panel.
|
||||
*/
|
||||
export function formatRawReply(buf: Buffer): string {
|
||||
// Latin-1 preserves every byte while removing addresses before either view is built.
|
||||
buf = Buffer.from(redactRconAddresses(buf.toString('latin1')), 'latin1');
|
||||
const bytes = buf.subarray(0, RAW_REPLY_LIMIT);
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < bytes.length; i += 16) {
|
||||
@@ -370,12 +369,12 @@ export function agentRconStatusResult(reply: AgentRconReply, endpoint: { host: s
|
||||
}) };
|
||||
}
|
||||
const players = parsePlayerCount(reply.text);
|
||||
if (players === null) return { ok: false, endpoint, reason: 'bad-reply', detail: 'Agent returned an unrecognized RCON status.', rawReply: reply.text.slice(0, 4000) };
|
||||
if (players === null) return { ok: false, endpoint, reason: 'bad-reply', detail: 'Agent returned an unrecognized RCON status.', rawReply: redactRconAddresses(reply.text).slice(0, 4000) };
|
||||
const info = parseInfoLine(reply.text); const header = parseStatusHeader(reply.text);
|
||||
return { ok: true, endpoint, status: { players,
|
||||
capacity: positiveCapacity(info.sv_maxclients ?? header.sv_maxclients),
|
||||
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(reply.text) }, rawReply: reply.text.slice(0, 4000) };
|
||||
gametype: info.g_gametype ?? header.g_gametype ?? header.gametype ?? null, roster: parsePlayerRoster(reply.text) } };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -455,11 +454,8 @@ async function probeStatus(ep: RconEndpoint): Promise<RconStatusResult> {
|
||||
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)`;
|
||||
// Failure summaries reach viewers. Keep the reply body in operator diagnostics.
|
||||
detail = `unparseable print reply (${text.length} bytes)`;
|
||||
} else {
|
||||
detail = `unparseable reply (${send.buf.length} bytes) — see snippet below`;
|
||||
}
|
||||
@@ -477,8 +473,7 @@ async function probeStatus(ep: RconEndpoint): Promise<RconStatusResult> {
|
||||
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)
|
||||
},
|
||||
rawReply: formatRawReply(send.buf)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ export interface ServerStatusEntry {
|
||||
lastError: string | null;
|
||||
endpoint: RconEndpoint | null;
|
||||
reason: RconFailure | null;
|
||||
replySnippet: string | null;
|
||||
activeSince: string | null;
|
||||
inactiveSince: string | null;
|
||||
gameStartedAt: string | null;
|
||||
|
||||
@@ -24,6 +24,7 @@ import { observedPlayerCapacity } from '../fleet/player-capacity.js';
|
||||
import type { PlayerCapacityObservation } from '../../server-capacity.js';
|
||||
import { withHostRequestContext } from '../hosts/request-context.js';
|
||||
import { withOperationSource } from '../hosts/operation-context.js';
|
||||
import { failureReplySnippet } from '../integrations/rcon-diagnostics.js';
|
||||
|
||||
const TICK_MS = 15_000;
|
||||
const RETENTION_INTERVAL_MS = 5 * 60_000;
|
||||
@@ -227,7 +228,7 @@ async function refreshServerStatus(server: string, snapshot: MonitoringSnapshot,
|
||||
gameObservation = probe;
|
||||
const now = probe.observedAt;
|
||||
updates.last_checked_at = now;
|
||||
updates.last_reply_snippet = result.rawReply ?? null;
|
||||
updates.last_reply_snippet = failureReplySnippet(result);
|
||||
if (result.ok) {
|
||||
capacity = result.status.capacity ?? null;
|
||||
Object.assign(updates, successfulStatusFields(result, now));
|
||||
@@ -261,7 +262,7 @@ export function statusEntry(srv: ServerRow, observe = serverObservation, capacit
|
||||
return { server: srv.name, players: final.last_players, capacity: capacity?.capacity ?? null,
|
||||
capacityObservedAt: capacity?.capacity_observed_at ?? null, map: final.last_map ?? srv.map,
|
||||
gametype: final.last_gametype, roster: final.roster, health: final.health, lastSeenAt: final.last_seen_at,
|
||||
lastError: final.last_error, replySnippet: final.replySnippet,
|
||||
lastError: final.last_error,
|
||||
endpoint: srv.game_port ? { host: srv.rcon_host ?? config.rcon.host, port: srv.game_port } : null, reason: final.last_error ? final.last_error.split(':')[0] as RconFailure : null,
|
||||
activeSince: final.active_since, inactiveSince: final.inactive_since,
|
||||
gameStartedAt: final.game_started_at, gameEndedAt: final.game_ended_at,
|
||||
|
||||
Reference in New Issue
Block a user