Protect RCON diagnostics and redact player addresses (Refs #61)
This commit is contained in:
@@ -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' } });
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user