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
@@ -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}