Protect RCON diagnostics and redact player addresses (Refs #61)

This commit is contained in:
2026-10-08 16:47:34 -04:00
parent 29d2d9634c
commit 0cc4498b53
36 changed files with 280 additions and 83 deletions
+2 -1
View File
@@ -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';
}
+10
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
};
}
+3 -2
View File
@@ -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;
}
+10 -15
View File
@@ -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)
}
};
}
-1
View File
@@ -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;
+3 -2
View File
@@ -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,
+1 -1
View File
@@ -43,7 +43,7 @@ export function createFleetStore(serverName?: string) {
const base = status ? { ...server, last_players: status.players, last_map: status.map,
capacity: status.capacity ?? null, capacity_observed_at: status.capacityObservedAt ?? null,
last_gametype: status.gametype, last_seen_at: status.lastSeenAt, last_error: status.lastError,
roster: status.roster, health: status.health, endpoint: status.endpoint, replySnippet: status.replySnippet, active_since: status.activeSince,
roster: status.roster, health: status.health, endpoint: status.endpoint, active_since: status.activeSince,
inactive_since: status.inactiveSince, game_started_at: status.gameStartedAt,
game_ended_at: status.gameEndedAt, container_state: status.containerState ?? server.container_state,
container_checked_at: status.containerCheckedAt ?? server.container_checked_at,
-1
View File
@@ -35,7 +35,6 @@ export interface ServerStatus {
lastError: string | null;
endpoint: RconEndpoint | null;
reason: RconFailure | null;
replySnippet: string | null;
activeSince: string | null;
inactiveSince: string | null;
gameStartedAt: string | null;
@@ -4,6 +4,7 @@ import { rconStatusResult } from '$lib/server/integrations/rcon.js';
import { getServer, setServerField } from '$lib/server/fleet/servers.js';
import { latestGameSubmission } from '$lib/server/fleet/status-evidence.js';
import { isProvisioning } from '$lib/server/fleet/provisioning.js';
import { failureReplySnippet } from '$lib/server/integrations/rcon-diagnostics.js';
/**
* On-demand RCON probe for a specific server. Same code path as the periodic
@@ -39,14 +40,14 @@ export const POST: RequestHandler = async ({ params }) => {
setServerField(name, 'last_checked_at', now);
setServerField(name, 'last_error', null);
setServerField(name, 'rcon_failures', 0);
setServerField(name, 'last_reply_snippet', result.rawReply);
setServerField(name, 'last_reply_snippet', null);
} else {
const encoded = `${result.reason}:${result.detail}`;
setServerField(name, 'last_checked_at', now);
setServerField(name, 'last_error', encoded);
// An explicitly requested diagnostic failure takes effect immediately.
setServerField(name, 'rcon_failures', 2);
setServerField(name, 'last_reply_snippet', result.rawReply ?? null);
setServerField(name, 'last_reply_snippet', failureReplySnippet(result));
}
return json(result);
@@ -0,0 +1,11 @@
import { json } from '@sveltejs/kit';
import { getServer } from '$lib/server/fleet/servers.js';
import { redactRconAddresses } from '$lib/server/integrations/rcon-diagnostics.js';
/** Saved failure evidence; the authentication guard restricts this route to operators. */
export function GET({ params }: { params: { name: string } }) {
const server = getServer(params.name);
if (!server || server.destroyed_at) return json({ error: 'No such server.' }, { status: 404 });
return json({ replySnippet: server.last_error && server.last_reply_snippet
? redactRconAddresses(server.last_reply_snippet) : null }, { headers: { 'Cache-Control': 'no-store' } });
}
+19 -15
View File
@@ -67,7 +67,7 @@
last_map: string | null; last_gametype: string | null;
last_seen_at: string | null; last_error: string | null;
roster: RosterPlayer[] | null;
health: Health; endpoint: RconEndpoint | null; replySnippet: string | null;
health: Health; endpoint: RconEndpoint | null;
held: Array<{ target: string; betaId: number }>;
active_since: string | null; inactive_since: string | null;
game_started_at: string | null; game_ended_at: string | null;
@@ -108,6 +108,7 @@
let telemetryOpen = $state(false);
let inventory = $state<InventoryEntry[]>([]);
let rconProbe = $state<RconProbeResult | null>(null);
let savedRconSnippet = $state<string | null>(null);
let probingRcon = $state(false);
let destroyOpen = $state(false);
const sectionIds = ['live', 'scoreboard', 'matches', 'chat', 'logs', 'configuration', 'config', 'inventory', 'diagnostics', 'history'];
@@ -134,14 +135,17 @@
let tabSequence = 0;
async function loadTab(currentName: string, tab: string, signal?: AbortSignal) {
if (tab !== 'configuration') return;
if (tab !== 'configuration' && (tab !== 'diagnostics' || !canOperate)) return;
const sequence = ++tabSequence;
try {
const response = await fetch('/api/servers/' + encodeURIComponent(currentName) + '/scripts', { signal });
if (!response.ok) throw new Error('Could not refresh inventory.');
const response = await fetch('/api/servers/' + encodeURIComponent(currentName) + (tab === 'diagnostics' ? '/rcon-diagnostics' : '/scripts'), { signal });
if (!response.ok) throw new Error(tab === 'diagnostics' ? 'Could not refresh RCON diagnostics.' : 'Could not refresh inventory.');
const body = await response.json();
if (sequence !== tabSequence || name !== currentName || activeSection !== tab || signal?.aborted) return;
inventory = body.scripts;
if (tab === 'diagnostics') {
if (!canOperate) return;
savedRconSnippet = body.replySnippet;
} else inventory = body.scripts;
tabError = '';
return true;
} catch (error) {
@@ -152,12 +156,12 @@
$effect(() => {
const currentName = name;
const tab = activeSection;
tabError = ''; inventory = []; inventoryLimit = 25;
tabLoading = tab === 'configuration';
if (tab !== 'configuration') return;
tabError = ''; inventory = []; inventoryLimit = 25; savedRconSnippet = null;
tabLoading = tab === 'configuration' || (tab === 'diagnostics' && canOperate);
if (tab !== 'configuration' && (tab !== 'diagnostics' || !canOperate)) return;
return pollVisible(signal => loadTab(currentName, tab, signal), 10000);
});
$effect(() => { name; rconProbe = null; });
$effect(() => { name; canOperate; rconProbe = null; });
const fresh = $derived(server ? freshServer(server, $uiNow) : false);
let effectivePlayers = $derived(fresh && health?.state === 'online' ? server?.last_players ?? null : null);
@@ -171,7 +175,7 @@
let effectiveReason = $derived<RconFailure | null>(decoded?.reason ?? null);
let errorDetail = $derived(decoded?.detail ?? effectiveErr ?? '');
let effectiveSnippet = $derived<string | null>(
rconProbe?.rawReply ?? server?.replySnippet ?? null
canOperate ? rconProbe ? rconProbe.rawReply ?? null : savedRconSnippet : null
);
let effectiveActiveSince = $derived<string | null>(server?.active_since ?? null);
let effectiveInactiveSince = $derived<string | null>(server?.inactive_since ?? null);
@@ -195,14 +199,14 @@
return `${Math.floor(d / 86_400_000)}d ago`;
}
async function probeRcon(): Promise<void> {
if (probingRcon || !name) return;
if (!canOperate || probingRcon || !name) return;
const currentName = name;
probingRcon = true; rconProbe = null;
try {
const res = await fetch(`/api/servers/${encodeURIComponent(currentName)}/probe-rcon`, { method: 'POST' });
const body = await res.json().catch(() => null);
if (!body || typeof body.ok !== 'boolean') throw new Error(`HTTP ${res.status}: Expected a JSON connection result.`);
if (name !== currentName) return;
if (!canOperate || name !== currentName) return;
rconProbe = body as RconProbeResult;
await refreshFleet();
} catch (err) {
@@ -300,7 +304,7 @@
</div>
<div class="connection-status">
{#if native}<div><strong>Host agent</strong><span>{server.host?.connected && freshStatusTimestamp(server.host.lastHeartbeat, $uiNow) ? 'Online' : 'Unavailable'}</span><small>{server.host?.lastHeartbeat ? `Last heartbeat ${ago(server.host.lastHeartbeat, $uiNow)}` : 'No heartbeat received'}</small>{#if server.host_id}<a href={contextHref(`/hosts/${server.host_id}`, $page.url)}>Inspect host</a>{/if}</div>{/if}
<div><strong>Game connection · RCON</strong><span>{effectiveReason ? 'Issue' : server.rcon_status?.last_seen_at && freshStatusTimestamp(server.rcon_status.last_seen_at, $uiNow) ? 'Responding' : 'No fresh response'}</span><small>{server.rcon_status?.last_seen_at ? `Last response ${ago(server.rcon_status.last_seen_at, $uiNow)}` : 'No response received'}</small>{#if effectiveEndpoint || native}<Button size="sm" variant="ghost" onclick={probeRcon} disabled={probingRcon} loading={probingRcon}>Test connection</Button>{/if}</div>
<div><strong>Game connection · RCON</strong><span>{effectiveReason ? 'Issue' : server.rcon_status?.last_seen_at && freshStatusTimestamp(server.rcon_status.last_seen_at, $uiNow) ? 'Responding' : 'No fresh response'}</span><small>{server.rcon_status?.last_seen_at ? `Last response ${ago(server.rcon_status.last_seen_at, $uiNow)}` : 'No response received'}</small>{#if canOperate && (effectiveEndpoint || native)}<Button size="sm" variant="ghost" onclick={probeRcon} disabled={probingRcon} loading={probingRcon}>Test connection</Button>{/if}</div>
<div><strong>Game telemetry</strong><span>{fresh && freshTelemetry(scoreboard, $uiNow, server.telemetry_after) ? 'Reporting' : scoreboard ? 'Last report outdated' : 'No report'}</span><small>{scoreboard ? `Last report ${ago(scoreboard.receivedAt, $uiNow)}` : 'Waiting for game data'}</small><button class="text-button" onclick={() => telemetryOpen = !telemetryOpen} aria-expanded={telemetryOpen}>Inspect requests</button></div>
</div>
<p class="sub">Player count source: {fresh ? server.status_source === 'telemetry' ? 'game telemetry' : server.status_source === 'rcon' ? 'RCON' : 'unavailable' : 'unavailable'}.</p>
@@ -317,9 +321,9 @@
</div>
{/if}
{#if effectiveSnippet}
{#if canOperate && effectiveSnippet}
<details class="snippet-details" open={effectiveReason === 'bad-reply'}>
<summary>Raw reply ({effectiveSnippet.split('\n').length - 1} lines)</summary>
<summary>Failure reply (addresses redacted)</summary>
<pre class="snippet">{effectiveSnippet}</pre>
</details>
{/if}