From 0cc4498b53091c9739e32a2b538ffd66ed61936f Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Thu, 8 Oct 2026 16:47:34 -0400 Subject: [PATCH] Protect RCON diagnostics and redact player addresses (Refs #61) --- docs/guides/authentication.md | 7 +- src/lib/server/auth/access.ts | 3 +- src/lib/server/db/index.ts | 10 +++ src/lib/server/db/schema.sql | 2 +- src/lib/server/fleet/status-updates.ts | 2 +- src/lib/server/fleet/status.ts | 5 +- .../server/integrations/rcon-diagnostics.ts | 11 +++ src/lib/server/integrations/rcon.ts | 25 +++--- src/lib/server/pipeline/bus.ts | 1 - src/lib/server/pipeline/worker.ts | 5 +- src/lib/ui/stores/fleet.ts | 2 +- src/lib/ui/stores/servers.ts | 1 - .../api/servers/[name]/probe-rcon/+server.ts | 5 +- .../[name]/rcon-diagnostics/+server.ts | 11 +++ src/routes/servers/[name]/+page.svelte | 34 ++++---- tests/unit/auth-access.test.ts | 2 +- tests/unit/auth-guard.test.ts | 3 + tests/unit/beta-plans.test.ts | 2 +- tests/unit/collection-cancellation.test.ts | 2 +- .../unit/collection-match-performance.test.ts | 2 +- tests/unit/deployment-collections.test.ts | 2 +- tests/unit/health-observations.test.ts | 2 +- tests/unit/launch-recipes.test.ts | 2 +- tests/unit/native-provisioning.test.ts | 6 +- tests/unit/native-safety.test.ts | 2 +- tests/unit/plutonium.test.ts | 2 +- tests/unit/rcon-diagnostics.test.ts | 84 +++++++++++++++++++ tests/unit/rcon-status.test.ts | 15 ++++ tests/unit/rcon.test.ts | 34 +++++++- tests/unit/rebalancing-runs.test.ts | 2 +- tests/unit/server-capacity.test.ts | 2 +- tests/unit/server-status.test.ts | 54 +++++++----- tests/unit/staged-files.test.ts | 2 +- tests/unit/ui-server-clock.test.ts | 2 +- tests/unit/ui-server-detail.test.ts | 15 +++- tests/unit/watchdog.test.ts | 2 +- 36 files changed, 280 insertions(+), 83 deletions(-) create mode 100644 src/lib/server/integrations/rcon-diagnostics.ts create mode 100644 src/routes/api/servers/[name]/rcon-diagnostics/+server.ts create mode 100644 tests/unit/rcon-diagnostics.test.ts diff --git a/docs/guides/authentication.md b/docs/guides/authentication.md index e533d86..3acd5de 100644 --- a/docs/guides/authentication.md +++ b/docs/guides/authentication.md @@ -34,7 +34,7 @@ guessable token. An unset setup token holds setup closed. | Capability | Viewer | Operator | Administrator | | --- | --- | --- | --- | | Fleet, metrics, matches and operation summaries | Yes | Yes | Yes | -| Raw configs, file diffs, logs and telemetry diagnostics | No | Yes | Yes | +| Raw configs, file diffs, logs, RCON and telemetry diagnostics | No | Yes | Yes | | Deploy/start/stop/restart/rebuild/remove servers | No | Yes | Yes | | Scripts, plugins, configs, campaigns, betas and collections | Read summaries | Manage | Manage | | Host setup, deletion, enrollment, credentials, runtime preparation and game-file upload/cache | Read status | Read status | Manage | @@ -47,6 +47,11 @@ cannot save keys indirectly through a launch request. An operator's content permissions include executable scripts and plugins; assign that role only to people trusted to operate the game fleet. +RCON failure replies are available only to operators and administrators in the +server's Diagnostics tab. Player addresses are redacted before diagnostics are +saved; successful checks clear the saved reply. Fleet snapshots and live status +streams contain no reply snippets. Upgrades discard older saved RCON snippets. + Permissions are checked on the server for API calls, normal pages and Svelte page-data requests. Hiding UI controls is a convenience, not the access boundary. The controller prevents disabling or demoting the last enabled administrator. diff --git a/src/lib/server/auth/access.ts b/src/lib/server/auth/access.ts index 1dcf3bb..ae9f895 100644 --- a/src/lib/server/auth/access.ts +++ b/src/lib/server/auth/access.ts @@ -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'; } diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 9332a87..aaa1468 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -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 diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index 732e562..9db3ca9 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -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 diff --git a/src/lib/server/fleet/status-updates.ts b/src/lib/server/fleet/status-updates.ts index 3bf8404..225aa99 100644 --- a/src/lib/server/fleet/status-updates.ts +++ b/src/lib/server/fleet/status-updates.ts @@ -8,7 +8,7 @@ export function successfulStatusFields(result: Extract @@ -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 { * `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 { 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 { 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) + } }; } diff --git a/src/lib/server/pipeline/bus.ts b/src/lib/server/pipeline/bus.ts index 68ca933..d6587f2 100644 --- a/src/lib/server/pipeline/bus.ts +++ b/src/lib/server/pipeline/bus.ts @@ -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; diff --git a/src/lib/server/pipeline/worker.ts b/src/lib/server/pipeline/worker.ts index 2161779..7d88995 100644 --- a/src/lib/server/pipeline/worker.ts +++ b/src/lib/server/pipeline/worker.ts @@ -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, diff --git a/src/lib/ui/stores/fleet.ts b/src/lib/ui/stores/fleet.ts index f821134..5ffe048 100644 --- a/src/lib/ui/stores/fleet.ts +++ b/src/lib/ui/stores/fleet.ts @@ -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, diff --git a/src/lib/ui/stores/servers.ts b/src/lib/ui/stores/servers.ts index 877b80c..331ea41 100644 --- a/src/lib/ui/stores/servers.ts +++ b/src/lib/ui/stores/servers.ts @@ -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; diff --git a/src/routes/api/servers/[name]/probe-rcon/+server.ts b/src/routes/api/servers/[name]/probe-rcon/+server.ts index 53401f2..a059298 100644 --- a/src/routes/api/servers/[name]/probe-rcon/+server.ts +++ b/src/routes/api/servers/[name]/probe-rcon/+server.ts @@ -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); diff --git a/src/routes/api/servers/[name]/rcon-diagnostics/+server.ts b/src/routes/api/servers/[name]/rcon-diagnostics/+server.ts new file mode 100644 index 0000000..6ac33d4 --- /dev/null +++ b/src/routes/api/servers/[name]/rcon-diagnostics/+server.ts @@ -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' } }); +} diff --git a/src/routes/servers/[name]/+page.svelte b/src/routes/servers/[name]/+page.svelte index 94bd8ae..6ffd84d 100644 --- a/src/routes/servers/[name]/+page.svelte +++ b/src/routes/servers/[name]/+page.svelte @@ -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([]); let rconProbe = $state(null); + let savedRconSnippet = $state(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(decoded?.reason ?? null); let errorDetail = $derived(decoded?.detail ?? effectiveErr ?? ''); let effectiveSnippet = $derived( - rconProbe?.rawReply ?? server?.replySnippet ?? null + canOperate ? rconProbe ? rconProbe.rawReply ?? null : savedRconSnippet : null ); let effectiveActiveSince = $derived(server?.active_since ?? null); let effectiveInactiveSince = $derived(server?.inactive_since ?? null); @@ -195,14 +199,14 @@ return `${Math.floor(d / 86_400_000)}d ago`; } async function probeRcon(): Promise { - 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 @@
{#if native}
Host agent{server.host?.connected && freshStatusTimestamp(server.host.lastHeartbeat, $uiNow) ? 'Online' : 'Unavailable'}{server.host?.lastHeartbeat ? `Last heartbeat ${ago(server.host.lastHeartbeat, $uiNow)}` : 'No heartbeat received'}{#if server.host_id}Inspect host{/if}
{/if} -
Game connection · RCON{effectiveReason ? 'Issue' : server.rcon_status?.last_seen_at && freshStatusTimestamp(server.rcon_status.last_seen_at, $uiNow) ? 'Responding' : 'No fresh response'}{server.rcon_status?.last_seen_at ? `Last response ${ago(server.rcon_status.last_seen_at, $uiNow)}` : 'No response received'}{#if effectiveEndpoint || native}{/if}
+
Game connection · RCON{effectiveReason ? 'Issue' : server.rcon_status?.last_seen_at && freshStatusTimestamp(server.rcon_status.last_seen_at, $uiNow) ? 'Responding' : 'No fresh response'}{server.rcon_status?.last_seen_at ? `Last response ${ago(server.rcon_status.last_seen_at, $uiNow)}` : 'No response received'}{#if canOperate && (effectiveEndpoint || native)}{/if}
Game telemetry{fresh && freshTelemetry(scoreboard, $uiNow, server.telemetry_after) ? 'Reporting' : scoreboard ? 'Last report outdated' : 'No report'}{scoreboard ? `Last report ${ago(scoreboard.receivedAt, $uiNow)}` : 'Waiting for game data'}

Player count source: {fresh ? server.status_source === 'telemetry' ? 'game telemetry' : server.status_source === 'rcon' ? 'RCON' : 'unavailable' : 'unavailable'}.

@@ -317,9 +321,9 @@ {/if} - {#if effectiveSnippet} + {#if canOperate && effectiveSnippet}
- Raw reply ({effectiveSnippet.split('\n').length - 1} lines) + Failure reply (addresses redacted)
{effectiveSnippet}
{/if} diff --git a/tests/unit/auth-access.test.ts b/tests/unit/auth-access.test.ts index b56cbc2..dded8be 100644 --- a/tests/unit/auth-access.test.ts +++ b/tests/unit/auth-access.test.ts @@ -64,7 +64,7 @@ describe('management route permissions', () => { '/configs/[id]', '/api/configs/[id]', '/api/configs/inspect', '/api/campaigns/[id]/file/[fileId]', '/api/deployments/[id]/file/[fileId]', '/api/betas/[id]/revisions/[revision]/files/[scriptId]', - '/api/servers/[name]/logs/[file]', '/api/servers/[name]/telemetry-requests' + '/api/servers/[name]/logs/[file]', '/api/servers/[name]/telemetry-requests', '/api/servers/[name]/rcon-diagnostics' ])('prevents viewer access to secret-bearing raw content at %s', route => { for (const method of ['GET', 'HEAD']) { expect(routePermission(route, method)).toBe('operator'); diff --git a/tests/unit/auth-guard.test.ts b/tests/unit/auth-guard.test.ts index 8dd8698..e95f28e 100644 --- a/tests/unit/auth-guard.test.ts +++ b/tests/unit/auth-guard.test.ts @@ -131,6 +131,9 @@ describe('browser authentication guard', () => { it.each([ ['viewer', '/api/fleet', 'GET', 200], ['viewer', '/api/servers', 'POST', 403], ['viewer', '/api/configs/[id]', 'GET', 403], ['operator', '/api/configs/[id]', 'GET', 200], + ['viewer', '/api/servers/[name]/rcon-diagnostics', 'GET', 403], + ['operator', '/api/servers/[name]/rcon-diagnostics', 'GET', 200], + ['admin', '/api/servers/[name]/rcon-diagnostics', 'GET', 200], ['operator', '/api/provisioning', 'POST', 200], ['operator', '/api/hosts/[id]', 'PATCH', 403], ['operator', '/api/users', 'GET', 403], ['admin', '/api/hosts/[id]', 'PATCH', 200], ['admin', '/api/users', 'GET', 200] ] as const)('enforces %s role for %s %s', async (role, routeId, method, expectedStatus) => { diff --git a/tests/unit/beta-plans.test.ts b/tests/unit/beta-plans.test.ts index 1e40ead..bca5fcc 100644 --- a/tests/unit/beta-plans.test.ts +++ b/tests/unit/beta-plans.test.ts @@ -44,7 +44,7 @@ beforeEach(async () => { await seedServerRegistry(); const { rconGetPlayerCount, rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js'); vi.mocked(rconGetPlayerCount).mockResolvedValue(0); - vi.mocked(rconStatusResult).mockResolvedValue({ ok: true, endpoint: { host: 'test', port: 1 }, rawReply: '', status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } }); + vi.mocked(rconStatusResult).mockResolvedValue({ ok: true, endpoint: { host: 'test', port: 1 }, status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } }); }); afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); vi.useRealTimers(); }); diff --git a/tests/unit/collection-cancellation.test.ts b/tests/unit/collection-cancellation.test.ts index d1a93b4..5ec1450 100644 --- a/tests/unit/collection-cancellation.test.ts +++ b/tests/unit/collection-cancellation.test.ts @@ -6,7 +6,7 @@ import type { CollectionMode } from '../../src/lib/deployment-plan.js'; vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ rconGetPlayerCount: vi.fn(async () => 0), rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null })), - rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 }, rawReply: '', + rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 }, status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } })) })); vi.mock('../../src/lib/server/integrations/plutonium.js', () => ({ diff --git a/tests/unit/collection-match-performance.test.ts b/tests/unit/collection-match-performance.test.ts index 6d17e0e..af07199 100644 --- a/tests/unit/collection-match-performance.test.ts +++ b/tests/unit/collection-match-performance.test.ts @@ -6,7 +6,7 @@ import { newFixture, type Fixture } from '../helpers/fixture.js'; vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ rconGetPlayerCount: vi.fn(async () => 0), rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null })), - rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 }, rawReply: '', + rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 }, status: { players: 0, map: 'zm_transit', gametype: null, roster: [] } })) })); diff --git a/tests/unit/deployment-collections.test.ts b/tests/unit/deployment-collections.test.ts index e8995e8..411f58e 100644 --- a/tests/unit/deployment-collections.test.ts +++ b/tests/unit/deployment-collections.test.ts @@ -12,7 +12,7 @@ vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ await new Promise(resolve => setTimeout(resolve, 2)); return { revision: 5354, error: null }; }), - rconStatusResult: vi.fn(async (server: string) => ({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: '', + rconStatusResult: vi.fn(async (server: string) => ({ ok: true, endpoint: { host: 'localhost', port: 28960 }, status: { players: 0, map: server.startsWith('t6_') ? 'zm_transit' : server.includes('_moon_') ? 'zombie_moon' : 'zombie_theater', gametype: null, roster: [] } })) })); vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}), kickProvisioning: vi.fn(async () => {}) })); diff --git a/tests/unit/health-observations.test.ts b/tests/unit/health-observations.test.ts index 44ba4db..f74b2ed 100644 --- a/tests/unit/health-observations.test.ts +++ b/tests/unit/health-observations.test.ts @@ -7,7 +7,7 @@ vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ rconStatusResult: const server = 'managed'; const now = Date.parse('2026-10-05T16:00:00Z'); const healthy = { ok: true as const, state: 'running' }; -const playing = { ok: true as const, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', +const playing = { ok: true as const, endpoint: { host: 'localhost', port: 28960 }, status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } }; let fixture: Fixture; diff --git a/tests/unit/launch-recipes.test.ts b/tests/unit/launch-recipes.test.ts index 3328f3d..14442e1 100644 --- a/tests/unit/launch-recipes.test.ts +++ b/tests/unit/launch-recipes.test.ts @@ -7,7 +7,7 @@ import { newFixture, type Fixture } from '../helpers/fixture.js'; vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ rconGetPlayerCount: vi.fn(async () => 0), rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null })), - rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: '', + rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'localhost', port: 28960 }, status: { players: 0, map: 'zombie_theater', gametype: null, roster: [] } })) })); vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}), kickProvisioning: vi.fn(async () => {}) })); diff --git a/tests/unit/native-provisioning.test.ts b/tests/unit/native-provisioning.test.ts index 0034e0d..e5e73fc 100644 --- a/tests/unit/native-provisioning.test.ts +++ b/tests/unit/native-provisioning.test.ts @@ -6,7 +6,7 @@ import { newFixture, type Fixture } from '../helpers/fixture.js'; const remote = vi.hoisted(() => ({ state: 'running', players: 0, map: 'zombie_theater' })); vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ - rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 }, rawReply: '', status: { players: remote.players, map: remote.map, gametype: 'zom', roster: [] } })), + rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 }, status: { players: remote.players, map: remote.map, gametype: 'zom', roster: [] } })), rconGetPlayerCount: vi.fn(async () => remote.players), rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null })) })); vi.mock('../../src/lib/server/integrations/plutonium.js', () => ({ checkRelease: vi.fn(async () => {}), freshRelease: () => 5354, probeVersion: vi.fn(async () => 5354) })); @@ -261,7 +261,7 @@ describe('provisioning health handoff', () => { const observedAt = new Date().toISOString(); const roster = [{ num: 0, name: 'Player', guid: '12345678901234567', score: 500, ping: 20 }]; const { rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js'); - vi.mocked(rconStatusResult).mockResolvedValueOnce({ ok: true, endpoint: { host: 'agent', port: 28960 }, rawReply: 'verified status', + vi.mocked(rconStatusResult).mockResolvedValueOnce({ ok: true, endpoint: { host: 'agent', port: 28960 }, status: { players: 1, map: remote.map, gametype: 'zom', roster } }); const { subscribe } = await import('../../src/lib/server/pipeline/bus.js'); const { getDeployment } = await import('../../src/lib/server/provisioning/history.js'); @@ -276,7 +276,7 @@ describe('provisioning health handoff', () => { expect((await detail(id)).state).toBe('done'); expect(visible).toEqual(['online']); expect(getServer(job.name)).toMatchObject({ last_checked_at: observedAt, last_seen_at: observedAt, - last_players: 1, last_roster: JSON.stringify(roster), last_reply_snippet: 'verified status', + last_players: 1, last_roster: JSON.stringify(roster), last_reply_snippet: null, last_error: null, rcon_failures: 0, active_since: observedAt, inactive_since: null }); for (const status of await observations(job.name)) expect(status).toMatchObject({ health: 'online', last_players: 1, roster }); }); diff --git a/tests/unit/native-safety.test.ts b/tests/unit/native-safety.test.ts index 68ae734..09ec41e 100644 --- a/tests/unit/native-safety.test.ts +++ b/tests/unit/native-safety.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { newFixture, type Fixture } from '../helpers/fixture.js'; vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ - rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 }, rawReply: '', status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } })), + rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 }, status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } })), rconGetPlayerCount: vi.fn(async () => 0), rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null })) })); vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}), kickProvisioning: vi.fn(async () => {}) })); diff --git a/tests/unit/plutonium.test.ts b/tests/unit/plutonium.test.ts index 1180398..a4fa795 100644 --- a/tests/unit/plutonium.test.ts +++ b/tests/unit/plutonium.test.ts @@ -24,7 +24,7 @@ beforeEach(async () => { const r = await import('../../src/lib/server/integrations/rcon.js'); vi.mocked(r.rconGetPlayerCount).mockResolvedValue(0); vi.mocked(r.rconPlutoniumVersion).mockResolvedValue({ revision: 5354, error: null }); - vi.mocked(r.rconStatusResult).mockResolvedValue({ ok: true, endpoint: { host: 'test', port: 28960 }, rawReply: '', status: { players: 0, map: null, gametype: null, roster: [] } }); + vi.mocked(r.rconStatusResult).mockResolvedValue({ ok: true, endpoint: { host: 'test', port: 28960 }, status: { players: 0, map: null, gametype: null, roster: [] } }); }); afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); vi.useRealTimers(); }); const api = () => import('../../src/lib/server/fleet/plutonium.js'); diff --git a/tests/unit/rcon-diagnostics.test.ts b/tests/unit/rcon-diagnostics.test.ts new file mode 100644 index 0000000..2752a5e --- /dev/null +++ b/tests/unit/rcon-diagnostics.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; + +let fixture: Fixture; +beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); }); +afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); }); + +async function setup() { + const database = await import('../../src/lib/server/db/index.js'); + database.db().prepare(`INSERT INTO servers(name,game_port,last_error,last_reply_snippet) + VALUES ('server',28960,'bad-reply:unrecognized status','private reply 192.0.2.123:28960')`).run(); + return database; +} + +it('excludes both snippet fields from list/detail/page snapshots and initial and live SSE frames', async () => { + await setup(); + const { GET: list } = await import('../../src/routes/api/servers/+server.js'); + const { GET: detail } = await import('../../src/routes/api/servers/[name]/+server.js'); + const { load } = await import('../../src/routes/servers/[name]/+page.server.js'); + const { GET: stream } = await import('../../src/routes/api/events/servers/+server.js'); + const { getServer } = await import('../../src/lib/server/fleet/servers.js'); + const { statusEntry } = await import('../../src/lib/server/pipeline/worker.js'); + const { emit } = await import('../../src/lib/server/pipeline/bus.js'); + const safe = (text: string) => { + for (const value of ['replySnippet', 'last_reply_snippet', 'private reply', '192.0.2.123']) expect(text).not.toContain(value); + }; + safe(await list().text()); + safe(await detail({ params: { name: 'server' } }).text()); + safe(JSON.stringify(load({ params: { name: 'server' } }))); + const response = await stream(); + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + try { + for (let index = 0; index < 3; index++) safe(decoder.decode((await reader.read()).value)); + emit({ type: 'server-status', entries: [statusEntry(getServer('server')!)] }); + const live = decoder.decode((await reader.read()).value); + expect(live).toContain('event: server-status'); + safe(live); + } finally { await reader.cancel(); } +}); + +it('serves saved failure diagnostics without caching or probing and omits successful or missing evidence', async () => { + const { db } = await setup(); + const rcon = await import('../../src/lib/server/integrations/rcon.js'); + const probe = vi.spyOn(rcon, 'rconStatusResult'); + const { GET } = await import('../../src/routes/api/servers/[name]/rcon-diagnostics/+server.js'); + const response = GET({ params: { name: 'server' } }); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(await response.json()).toEqual({ replySnippet: 'private reply [redacted]' }); + expect(probe).not.toHaveBeenCalled(); + db().prepare("UPDATE servers SET last_error=NULL WHERE name='server'").run(); + expect(await GET({ params: { name: 'server' } }).json()).toEqual({ replySnippet: null }); + expect(GET({ params: { name: 'missing' } }).status).toBe(404); + db().prepare("UPDATE servers SET destroyed_at='2026-10-08' WHERE name='server'").run(); + expect(GET({ params: { name: 'server' } }).status).toBe(404); +}); + +it('sanitizes manually probed failures before persistence and clears them on success', async () => { + const { db } = await setup(); + const rcon = await import('../../src/lib/server/integrations/rcon.js'); + const probe = vi.spyOn(rcon, 'rconStatusResult'); + probe.mockResolvedValue({ ok: false, endpoint: null, reason: 'bad-reply', detail: 'unrecognized status', + rawReply: 'unrecognized player 192.0.2.123:28960' }); + const { POST } = await import('../../src/routes/api/servers/[name]/probe-rcon/+server.js'); + await POST({ params: { name: 'server' } } as never); + expect(db().prepare("SELECT last_reply_snippet FROM servers WHERE name='server'").get()) + .toEqual({ last_reply_snippet: 'unrecognized player [redacted]' }); + probe.mockResolvedValue({ ok: true, endpoint: { host: 'agent', port: 1 }, status: { players: 0, map: null, gametype: null, roster: [] } }); + expect(await (await POST({ params: { name: 'server' } } as never)).json()).not.toHaveProperty('rawReply'); + expect(db().prepare("SELECT last_reply_snippet FROM servers WHERE name='server'").get()).toEqual({ last_reply_snippet: null }); +}); + +it('discards legacy plain text and hex snippets and unsafe error details once during upgrade', async () => { + const { db, closeDb } = await setup(); + db().exec(`DELETE FROM settings WHERE key='rcon_private_diagnostics_v1'; + UPDATE servers SET last_error='bad-reply:print reply not recognized: "player 192.0.2.123:28960"'; + INSERT INTO servers(name,last_reply_snippet) VALUES ('legacy-hex','0000 31 39 32 2e 30 2e 32 2e 31 32 33 3a 32 38 39 36 192.0.2.123:2896');`); + closeDb(); + expect(db().prepare('SELECT last_reply_snippet FROM servers').all()).toEqual([{ last_reply_snippet: null }, { last_reply_snippet: null }]); + expect(db().prepare("SELECT last_error FROM servers WHERE name='server'").get()).toEqual({ last_error: 'bad-reply:unparseable print reply' }); + db().prepare("UPDATE servers SET last_reply_snippet='new sanitized failure' WHERE name='server'").run(); + closeDb(); + expect(db().prepare("SELECT last_reply_snippet FROM servers WHERE name='server'").get()).toEqual({ last_reply_snippet: 'new sanitized failure' }); +}); diff --git a/tests/unit/rcon-status.test.ts b/tests/unit/rcon-status.test.ts index 00ee33f..4f7e6a1 100644 --- a/tests/unit/rcon-status.test.ts +++ b/tests/unit/rcon-status.test.ts @@ -48,6 +48,20 @@ afterEach(() => { }); describe('RCON status transport', () => { + it('keeps reply bodies out of public failure summaries and redacts UDP diagnostics', async () => { + const request = rconStatusResult(server.name); + await vi.advanceTimersByTimeAsync(0); + sockets[0].emit('message', packet('print\nplayer 192.0.2.123:28960 unexpected response')); + const result = await request; + expect(result).toMatchObject({ ok: false, reason: 'bad-reply' }); + if (result.ok) throw new Error('Expected an unrecognized reply'); + expect(result.detail).not.toContain('player'); + expect(result.detail).not.toContain('192.0.2'); + const hex = result.rawReply!.split('\n').map(line => line.slice(6, 53).trim()).join(' ').replaceAll(' ', ''); + const decoded = Buffer.from(hex, 'hex').toString(); + expect(decoded).toContain('[redacted]'); + expect(decoded).not.toContain('192.0.2'); + }); it.each([ ['timeout', 'timeout', 'Agent received no reply'], ['unreachable', 'send-error', 'Agent could not reach'], @@ -79,6 +93,7 @@ describe('RCON status transport', () => { expect(sockets[0].send.mock.calls[0][0].toString()).toContain('rcon test status\n'); reply(); expect(await health).toMatchObject({ ok: true, status: { players: 1, map: 'mp_kino' } }); + expect(await health).not.toHaveProperty('rawReply'); expect(await occupancy).toBe(1); expect(await duplicate).toEqual(await health); expect(sockets[0].close).toHaveBeenCalledOnce(); diff --git a/tests/unit/rcon.test.ts b/tests/unit/rcon.test.ts index b775a41..228af6d 100644 --- a/tests/unit/rcon.test.ts +++ b/tests/unit/rcon.test.ts @@ -4,8 +4,40 @@ import { parsePlayerCount, parsePlayerRoster, parseStatusHeader, - formatRawReply + formatRawReply, + agentRconStatusResult } from '../../src/lib/server/integrations/rcon.js'; +import { redactRconAddresses } from '../../src/lib/server/integrations/rcon-diagnostics.js'; + +describe('private RCON diagnostics', () => { + const addresses = ['192.0.2.123:28960', '198.51.100.9', '[2001:db8::1]:28960', + '2001:db8::abcd', '[::ffff:192.0.2.4]:28960', '[fe80::1%eth0]:1234', '::1']; + + it.each(addresses)('redacts %s from both plain text and decoded hex diagnostics', address => { + const text = `unrecognized status\nplayer address ${address} trailing text`; + expect(redactRconAddresses(text)).toBe('unrecognized status\nplayer address [redacted] trailing text'); + const dump = formatRawReply(Buffer.from(text)); + const hex = dump.split('\n').map(line => line.slice(6, 53).trim()).join(' ').replaceAll(' ', ''); + const decoded = Buffer.from(hex, 'hex').toString(); + expect(decoded).toBe(redactRconAddresses(text)); + }); + + it('redacts complete addresses before truncating native and UDP replies', () => { + const native = agentRconStatusResult({ text: 'x'.repeat(3990) + ' 192.0.2.123:28960' }, { host: 'agent', port: 1 }); + expect(native).toMatchObject({ ok: false }); + expect(native.rawReply).not.toContain('192.0.2'); + expect(native.rawReply!.length).toBeLessThanOrEqual(4000); + const dump = formatRawReply(Buffer.from('x'.repeat(500) + ' 192.0.2.123:28960')); + const hex = dump.split('\n').filter(line => /^[0-9a-f]{4} /.test(line)).map(line => line.slice(6, 53).trim()).join(' ').replaceAll(' ', ''); + expect(Buffer.from(hex, 'hex').toString()).not.toContain('192.0.2'); + }); + + it('omits native success replies while preserving the parsed status', () => { + const result = agentRconStatusResult({ text: 'print\nmap: mp_kino\nnum score ping guid name address\n--- ----- ---- ---- ---- -------\n' }, { host: 'agent', port: 1 }); + expect(result).toMatchObject({ ok: true, status: { players: 0, map: 'mp_kino' } }); + expect(result).not.toHaveProperty('rawReply'); + }); +}); describe('parsePlayerCount', () => { it('counts rows after --- --- separator (status dialect)', () => { diff --git a/tests/unit/rebalancing-runs.test.ts b/tests/unit/rebalancing-runs.test.ts index 41d2d3e..287e464 100644 --- a/tests/unit/rebalancing-runs.test.ts +++ b/tests/unit/rebalancing-runs.test.ts @@ -7,7 +7,7 @@ import type { RebalanceMove } from '../../src/lib/rebalancing.js'; vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}), kickProvisioning: vi.fn(async () => {}) })); vi.mock('../../src/lib/server/integrations/plutonium.js', () => ({ checkRelease: vi.fn(async () => {}), freshRelease: () => 5354, probeVersion: vi.fn(async () => 5354) })); vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ - rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 }, rawReply: '', + rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 }, status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } })), rconGetPlayerCount: vi.fn(async () => 0), rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null })) })); diff --git a/tests/unit/server-capacity.test.ts b/tests/unit/server-capacity.test.ts index 571cda5..b81eb06 100644 --- a/tests/unit/server-capacity.test.ts +++ b/tests/unit/server-capacity.test.ts @@ -24,7 +24,7 @@ async function setup(count = 1) { const { monitoringStatusWriter } = await import('../../src/lib/server/fleet/health-observations.js'); function publish(name: string, capacity: number | null | undefined = 4, observedAt = iso(), ok = true) { monitoringStatusWriter(name)({ observedAt: new Date().toISOString(), result: { ok: true, state: 'running' } }, - { observedAt, result: ok ? { ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', + { observedAt, result: ok ? { ok: true, endpoint: { host: 'localhost', port: 28960 }, status: { players: 3, capacity, map: 'zombie_theater', gametype: 'zom', roster: [] } } : { ok: false, endpoint: null, reason: 'timeout', detail: 'No reply' } }); } diff --git a/tests/unit/server-status.test.ts b/tests/unit/server-status.test.ts index e3905ae..d7be027 100644 --- a/tests/unit/server-status.test.ts +++ b/tests/unit/server-status.test.ts @@ -28,6 +28,22 @@ async function setup() { } describe('database fleet and container readiness', () => { + it('retains only sanitized failed replies and clears them after a successful monitoring check', async () => { + const { inspect, rcon, fanoutStatus, getServer } = await setup(); + vi.useFakeTimers({ toFake: ['Date'] }); + const at = Date.parse('2026-10-08T12:00:00Z'); + vi.setSystemTime(at); + inspect.mockResolvedValue({ ok: true, state: 'running' }); + rcon.mockResolvedValue({ ok: false, endpoint: null, reason: 'bad-reply', detail: 'unrecognized status', + rawReply: 'player 192.0.2.123:28960 invalid reply' }); + await fanoutStatus(); + expect(getServer('managed')?.last_reply_snippet).toBe('player [redacted] invalid reply'); + vi.setSystemTime(at + 30_000); + rcon.mockResolvedValue({ ok: true, endpoint: { host: 'agent', port: 1 }, + status: { players: 0, map: null, gametype: null, roster: [] } }); + await fanoutStatus(); + expect(getServer('managed')?.last_reply_snippet).toBeNull(); + }); it('publishes capacity from accepted monitoring samples and clears it on the next failed check', async () => { const { inspect, rcon, fanoutStatus } = await setup(); const { GET } = await import('../../src/routes/api/servers/+server.js'); @@ -36,7 +52,7 @@ describe('database fleet and container readiness', () => { const at = Date.parse('2026-10-07T12:00:00Z'); vi.setSystemTime(at); inspect.mockResolvedValue({ ok: true, state: 'running' }); - rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', + rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, status: { players: 3, capacity: 4, map: 'zombie_theater', gametype: 'zom', roster: [] } }); const events: unknown[] = []; const stop = subscribe(event => { if (event.type === 'server-status') events.push(event); }); @@ -61,7 +77,7 @@ describe('database fleet and container readiness', () => { const at = Date.parse('2026-10-05T12:00:00Z'); vi.setSystemTime(at); inspect.mockResolvedValue({ ok: true, state: 'running' }); - const success = { ok: true as const, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', + const success = { ok: true as const, endpoint: { host: 'localhost', port: 28960 }, status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } }; rcon.mockResolvedValue(success); await fanoutStatus(); @@ -130,7 +146,7 @@ describe('database fleet and container readiness', () => { const at = Date.parse('2026-10-05T12:00:00Z'); vi.setSystemTime(at); inspect.mockResolvedValue({ ok: true, state: 'running' }); - rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', + rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } }); await inspectForMonitoring('managed'); await rconForMonitoring('managed'); @@ -162,7 +178,7 @@ describe('database fleet and container readiness', () => { await vi.waitFor(() => expect(rcon).toHaveBeenCalledTimes(1)); vi.setSystemTime(at + 10_000); db.prepare("UPDATE servers SET last_checked_at=?,last_error='timeout:manual failure' WHERE name='managed'").run(new Date().toISOString()); - release({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'old reply', + release({ ok: true, endpoint: { host: 'localhost', port: 28960 }, status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } }); await pending; expect(getServer('managed')).toMatchObject({ last_error: 'timeout:manual failure', last_seen_at: null, last_checked_at: new Date(at + 10_000).toISOString() }); @@ -189,7 +205,7 @@ describe('database fleet and container readiness', () => { : { ok: true as const, state: 'running' }; } finally { active--; } }); - rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', + rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } }); const events: unknown[] = []; const unsubscribe = subscribe(event => events.push(event)); @@ -244,7 +260,7 @@ describe('database fleet and container readiness', () => { try { await (host === 'host-0' ? slow : fast); if (name === 'host-0-0') throw new Error('simulated failed probe'); - return { ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', + return { ok: true, endpoint: { host: 'localhost', port: 28960 }, status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } }; } finally { active.set(host, active.get(host)! - 1); } }); @@ -283,7 +299,7 @@ describe('database fleet and container readiness', () => { inspect.mockImplementation(async name => name === 'slow' ? await new Promise<{ ok: true; state: string }>(resolve => { release = resolve; }) : { ok: true as const, state: 'running' }); - rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', + rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } }); const pending = fanoutStatus(); try { @@ -305,7 +321,7 @@ describe('database fleet and container readiness', () => { rcon.mockImplementation(() => new Promise(resolve => { release = resolve; })); const pending = POST({ params: { name: 'managed' } } as never); db.prepare("INSERT INTO restart_rollout (server, requested_at) VALUES ('managed', ?)").run(new Date().toISOString()); - release({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'old status', + release({ ok: true, endpoint: { host: 'localhost', port: 28960 }, status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } }); expect(await (await pending).json()).toMatchObject({ ok: true }); expect(getServer('managed')).toMatchObject({ last_seen_at: null, last_error: 'timeout:old process' }); @@ -321,7 +337,7 @@ describe('database fleet and container readiness', () => { }); rcon.mockImplementation(async () => { if (phase === 'rcon') await blocked; - return { ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'old status', + return { ok: true, endpoint: { host: 'localhost', port: 28960 }, status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } }; }); const pending = fanoutStatus(); @@ -346,7 +362,7 @@ describe('database fleet and container readiness', () => { expect(inspect).not.toHaveBeenCalled(); expect(rcon).not.toHaveBeenCalled(); expect(events).toContainEqual(expect.objectContaining({ type: 'server-status', entries: [expect.objectContaining({ - health: 'provisioning', lastError: null, reason: null, replySnippet: null + health: 'provisioning', lastError: null, reason: null })] })); db.prepare("UPDATE server_deployments SET state = 'failed' WHERE id = 'creation'").run(); inspect.mockResolvedValue({ ok: true, state: 'running' }); @@ -354,7 +370,7 @@ describe('database fleet and container readiness', () => { await fanoutStatus(); expect(await snapshot()).toMatchObject({ health: 'offline', last_error: 'timeout:no reply' }); db.prepare("UPDATE server_deployments SET state = 'done' WHERE id = 'creation'").run(); - rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', + rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } }); await fanoutStatus(); expect(await snapshot()).toMatchObject({ health: 'online', last_error: null }); @@ -363,7 +379,7 @@ describe('database fleet and container readiness', () => { it('keeps game_end through occupied polls until game_start or an empty lobby', async () => { const { inspect, rcon, fanoutStatus, getServer } = await setup(); inspect.mockResolvedValue({ ok: true, state: 'running' }); - const reply = (players: number) => ({ ok: true as const, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', + const reply = (players: number) => ({ ok: true as const, endpoint: { host: 'localhost', port: 28960 }, status: { players, map: 'zombie_theater', gametype: 'zom', roster: [] } }); rcon.mockResolvedValue(reply(1)); await postTelemetry(gameEvent('game_start', 0, 1, 'managed')); @@ -396,7 +412,7 @@ describe('database fleet and container readiness', () => { vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(startedAt); inspect.mockResolvedValue({ ok: true, state: 'running' }); - rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', + rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, status: { players: 1, map: 'zombie_theater', gametype: 'zom', roster: [] } }); expect((await postTelemetry(gameEvent('game_start', 0, 1, 'managed'))).status).toBe(200); expect(getServer('managed')).toMatchObject({ active_since: at(0), inactive_since: null, game_started_at: at(0) }); @@ -430,7 +446,7 @@ describe('database fleet and container readiness', () => { vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(startedAt - 20_000); inspect.mockResolvedValue({ ok: true, state: 'running' }); - rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', + rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, status: { players: 1, map: 'zombie_theater', gametype: 'zom', roster: [] } }); await fanoutStatus(); expect(getServer('managed')).toMatchObject({ active_since: at(-20), game_started_at: null }); @@ -485,7 +501,7 @@ describe('database fleet and container readiness', () => { await mkdir(logs, { recursive: true }); await writeFile(join(logs, 'games_sp.log'), '0:00 InitGame:\n0:01 J;123;0;Player\n'); inspect.mockResolvedValue({ ok: true, state: 'running' }); - rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', + rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, status: { players: 1, map: 'zombie_theater', gametype: 'zom', roster: [] } }); await fanoutStatus(); expect(getServer('managed')).toMatchObject({ active_since: expect.any(String), game_started_at: null }); @@ -524,7 +540,7 @@ describe('database fleet and container readiness', () => { const telemetry = gameEvent('heartbeat', 15, 3, 'managed', map); telemetry.players[0].ping = 42; telemetry.players[1].guid = '7097376'; - rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', + rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, status: { players: 4, map, gametype: 'zom', roster: [ { num: 0, guid: telemetry.players[0].guid, name: 'Old name', score: 50, ping: 100 }, { num: 1, guid: game === 't6' ? '6C4C20' : telemetry.players[1].guid, name: 'Another old name', score: 100, ping: 30 }, @@ -574,7 +590,7 @@ describe('database fleet and container readiness', () => { inspect.mockResolvedValue({ ok: true, state: 'running' }); await postTelemetry(gameEvent('heartbeat', 15, 3, 'managed')); db.prepare('UPDATE server_telemetry SET received_at = ?').run(new Date(Date.now() - 91_000).toISOString()); - rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', + rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } }); await fanoutStatus(); expect(rcon).toHaveBeenCalledTimes(1); @@ -605,7 +621,7 @@ describe('database fleet and container readiness', () => { expect(rcon).toHaveBeenCalledTimes(1); expect(getServer('managed')).toMatchObject({ container_state: 'running', last_error: 'timeout:no reply' }); - rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', + rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } }); await fanoutStatus(); expect((await (await GET()).json()).servers[0]).toMatchObject({ health: 'online', last_error: null }); @@ -629,7 +645,7 @@ describe('database fleet and container readiness', () => { if (state === 'throw') inspect.mockRejectedValue(new Error('Inspection timed out')); else inspect.mockResolvedValue({ ok: false, state, reason: 'Inspection timed out' }); await postTelemetry(gameEvent('heartbeat', 15, 3, 'managed')); - rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status', + rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } }); const events: unknown[] = []; const unsubscribe = subscribe(event => events.push(event)); diff --git a/tests/unit/staged-files.test.ts b/tests/unit/staged-files.test.ts index 9ded8f5..b99096b 100644 --- a/tests/unit/staged-files.test.ts +++ b/tests/unit/staged-files.test.ts @@ -38,7 +38,7 @@ beforeEach(async () => { const { rconGetPlayerCount, rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js'); vi.mocked(rconGetPlayerCount).mockResolvedValue(0); vi.mocked(rconStatusResult).mockResolvedValue({ - ok: true, endpoint: { host: 'test', port: 28960 }, rawReply: '', + ok: true, endpoint: { host: 'test', port: 28960 }, status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } }); }); diff --git a/tests/unit/ui-server-clock.test.ts b/tests/unit/ui-server-clock.test.ts index ae3628e..95b35af 100644 --- a/tests/unit/ui-server-clock.test.ts +++ b/tests/unit/ui-server-clock.test.ts @@ -34,7 +34,7 @@ function status(timestamp: number, changes: Partial = {}): ServerS return { server: 't5_zombie_theater_1', players: 0, map: 'zombie_theater', gametype: 'zom', roster: [], health: 'online', lastSeenAt: new Date(timestamp).toISOString(), - lastError: null, endpoint: null, reason: null, replySnippet: null, + lastError: null, endpoint: null, reason: null, activeSince: null, inactiveSince: new Date(epoch).toISOString(), gameStartedAt: null, gameEndedAt: null, ...changes }; diff --git a/tests/unit/ui-server-detail.test.ts b/tests/unit/ui-server-detail.test.ts index c57d566..f4bc7cc 100644 --- a/tests/unit/ui-server-detail.test.ts +++ b/tests/unit/ui-server-detail.test.ts @@ -55,12 +55,12 @@ it('omits the fixed instance selector while keeping promoted/live distinction', expect(body).toContain('Live files and active betas may differ'); }); -function pageBody(tab: string) { - (page as unknown as ReturnType).set({ data: { user: { role: 'admin' } }, params: { name: 't5_zombie_theater_1' }, url: new URL(`http://localhost/servers/t5_zombie_theater_1?tab=${tab}&returnTo=%2F%3Ffilter%3Doccupied`) }); +function pageBody(tab: string, role = 'admin') { + (page as unknown as ReturnType).set({ data: { user: { role } }, params: { name: 't5_zombie_theater_1' }, url: new URL(`http://localhost/servers/t5_zombie_theater_1?tab=${tab}&returnTo=%2F%3Ffilter%3Doccupied`) }); const telemetry = report(); const server = { name: 't5_zombie_theater_1', game: 't5', map: 'zombie_theater', last_map: 'zombie_theater', health: 'online', last_players: 1, last_seen_at: telemetry.receivedAt, last_error: null, held: [], telemetry, status_source: 'telemetry', host_id: 'host-1', native_instance_id: 'instance-1', - host: { name: 'ec-1', connected: true, lastHeartbeat: telemetry.receivedAt }, + host: { name: 'ec-1', connected: true, lastHeartbeat: telemetry.receivedAt }, replySnippet: 'legacy private reply 192.0.2.123:28960', rcon_status: { last_seen_at: null, last_error: 'timeout: no reply' }, game_started_at: null, game_ended_at: null, active_since: null, inactive_since: null }; const data = { serverSnapshot: { servers: [server], operations: [], serverNow: get(uiNow), integrations: {}, reservedNames: [] } } as unknown as ComponentProps['data']; return render(ServerPage, { props: { data } }).body; @@ -83,6 +83,15 @@ it('reports RCON failures independently of fresh telemetry and defers the reques expect(body).toContain('Connection details'); expect(body).not.toContain('Telemetry API requests'); expect(body).not.toContain('>Healthy<'); + expect(body).toContain('Test connection'); + expect(body).not.toContain('legacy private reply'); +}); + +it('keeps connection summaries visible to viewers without raw diagnostics or probe controls', () => { + const body = pageBody('diagnostics', 'viewer'); + expect(body).toContain('Game connection · RCON'); + expect(body).not.toContain('Test connection'); + expect(body).not.toContain('legacy private reply'); }); it('uses the shared Activity view for server history', () => { diff --git a/tests/unit/watchdog.test.ts b/tests/unit/watchdog.test.ts index e903b1b..885052d 100644 --- a/tests/unit/watchdog.test.ts +++ b/tests/unit/watchdog.test.ts @@ -9,7 +9,7 @@ vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ rconGetPlayerCount vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) })); const server = 't5_zombie_theater_1'; -const healthyRcon = { ok: true as const, endpoint: { host: 'test', port: 28960 }, rawReply: '', +const healthyRcon = { ok: true as const, endpoint: { host: 'test', port: 28960 }, status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } }; const missingRcon = { ok: false as const, endpoint: { host: 'test', port: 28960 }, reason: 'timeout' as const, detail: 'No reply' }; let fixture: Fixture;