Compare commits

...
2 Commits
37 changed files with 281 additions and 83 deletions
+1
View File
@@ -7,3 +7,4 @@
- Keep unrelated features or fixes in separate commits. If a request includes two unrelated features, complete, validate, and commit each one before starting the next. - Keep unrelated features or fixes in separate commits. If a request includes two unrelated features, complete, validate, and commit each one before starting the next.
- Stage only changes belonging to the current task. Leave unrelated pre-existing changes out of the commit. - Stage only changes belonging to the current task. Leave unrelated pre-existing changes out of the commit.
- Use a concise commit message that describes the change. - Use a concise commit message that describes the change.
- When the user links a repository issue, reference its issue number in the corresponding commit message (for example, `Refs #59`).
+6 -1
View File
@@ -34,7 +34,7 @@ guessable token. An unset setup token holds setup closed.
| Capability | Viewer | Operator | Administrator | | Capability | Viewer | Operator | Administrator |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Fleet, metrics, matches and operation summaries | Yes | Yes | Yes | | 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 | | Deploy/start/stop/restart/rebuild/remove servers | No | Yes | Yes |
| Scripts, plugins, configs, campaigns, betas and collections | Read summaries | Manage | Manage | | 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 | | 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 permissions include executable scripts and plugins; assign that role only to
people trusted to operate the game fleet. 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 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. page-data requests. Hiding UI controls is a convenience, not the access boundary.
The controller prevents disabling or demoting the last enabled administrator. The controller prevents disabling or demoting the last enabled administrator.
+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' 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/campaigns/[id]/file/[fileId]' || routeId === '/api/deployments/[id]/file/[fileId]'
|| routeId === '/api/betas/[id]/revisions/[revision]/files/[scriptId]' || 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'; 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'" } { name: 'origin', ddl: "ALTER TABLE maintenance_jobs ADD COLUMN origin TEXT NOT NULL DEFAULT 'manual'" }
]); ]);
migrateServers(handle); 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); backfillDesiredState(handle);
// Column additions must run BEFORE any statement that references the // Column additions must run BEFORE any statement that references the
// new columns. schema.sql cannot include those references — see 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_seen_at TEXT, -- ISO ts of last successful RCON reply
last_error TEXT, -- last RCON failure reason (cleared on success) last_error TEXT, -- last RCON failure reason (cleared on success)
rcon_failures INTEGER NOT NULL DEFAULT 0, -- consecutive monitoring failures, capped at two 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. -- Provisioning fields — set when Electric Cherry creates the deployment.
game_mode TEXT, -- 'zombie' | 'mp' | 'sp' (currently zombie only) game_mode TEXT, -- 'zombie' | 'mp' | 'sp' (currently zombie only)
map_slug TEXT, -- 'cosmodrome' — short slug used in server name 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_checked_at: observedAt, last_seen_at: observedAt,
last_players: result.status.players, last_map: result.status.map, last_players: result.status.players, last_map: result.status.map,
last_gametype: result.status.gametype, last_roster: JSON.stringify(result.status.roster), 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 state = (evidence ? evidence.settling : restartSettling(server.name)) ? 'restarting' : server.container_state;
const ready = !provisioning && (!state || state === 'running' || state === 'unknown'); const ready = !provisioning && (!state || state === 'running' || state === 'unknown');
const health = provisioning ? 'provisioning' : classifyHealth(server.last_seen_at, rconHealthError(rcon), state); 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, status_source: null as 'telemetry' | 'rcon' | null,
telemetry: (evidence ? evidence.telemetry : telemetryReport(server.name)) ?? undefined, telemetry: (evidence ? evidence.telemetry : telemetryReport(server.name)) ?? undefined,
telemetry_after: evidence ? evidence.submission : latestGameSubmission(server.name), telemetry_after: evidence ? evidence.submission : latestGameSubmission(server.name),
last_error: ready ? server.last_error : null, 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, 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 game_started_at: ready ? server.game_started_at : null, game_ended_at: ready ? server.game_ended_at : null
}, undefined, Date.now()); }, 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 { latestGameSubmission } from '../fleet/lifecycle-evidence.js';
import { parsePlutoniumRevision } from '../../plutonium.js'; import { parsePlutoniumRevision } from '../../plutonium.js';
import { nativeInstance, nativeRequest } from '../backends/native.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 // Plutonium/CoD RCON is UDP with a 4-byte 0xff prefix. Requests look like
// \xff\xff\xff\xffrcon <password> <command> // \xff\xff\xff\xffrcon <password> <command>
@@ -306,18 +307,14 @@ export type RconStatusResult =
ok: true; ok: true;
endpoint: RconEndpoint; endpoint: RconEndpoint;
status: RconStatus; status: RconStatus;
// Kept on success too, so operators can compare "what we parsed" rawReply?: never;
// vs "what the server actually sent" from the detail page.
rawReply: string;
} }
| { | {
ok: false; ok: false;
endpoint: RconEndpoint | null; endpoint: RconEndpoint | null;
reason: RconFailure; reason: RconFailure;
detail: string; detail: string;
// Present on bad-reply: printable hex + ASCII dump of the raw UDP // Sanitized failure evidence, available only through operator diagnostics.
// payload so the operator can see what the server actually said
// (wrong password body, unexpected dialect, etc).
rawReply?: string; 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. * `hexdump -C`, capped at RAW_REPLY_LIMIT bytes so it fits in the UI panel.
*/ */
export function formatRawReply(buf: Buffer): string { 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 bytes = buf.subarray(0, RAW_REPLY_LIMIT);
const lines: string[] = []; const lines: string[] = [];
for (let i = 0; i < bytes.length; i += 16) { 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); 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); const info = parseInfoLine(reply.text); const header = parseStatusHeader(reply.text);
return { ok: true, endpoint, status: { players, return { ok: true, endpoint, status: { players,
capacity: positiveCapacity(info.sv_maxclients ?? header.sv_maxclients), capacity: positiveCapacity(info.sv_maxclients ?? header.sv_maxclients),
map: info.mapname ?? info.sv_mapname ?? header.map ?? header.mapname ?? header.sv_mapname ?? null, 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) { if (looksLikePrint && suggestsPassword) {
detail = 'wrong RCON password'; detail = 'wrong RCON password';
} else if (looksLikePrint) { } else if (looksLikePrint) {
// print body but we couldn't classify — show the first line // Failure summaries reach viewers. Keep the reply body in operator diagnostics.
const firstLine = trimmed.split(/\r?\n/)[1]?.trim() ?? ''; detail = `unparseable print reply (${text.length} bytes)`;
detail = firstLine
? `print reply not recognized: "${firstLine.slice(0, 100)}"`
: `unparseable print reply (${text.length} bytes)`;
} else { } else {
detail = `unparseable reply (${send.buf.length} bytes) — see snippet below`; 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, map: info.mapname ?? info.sv_mapname ?? header.map ?? header.mapname ?? header.sv_mapname ?? null,
gametype: info.g_gametype ?? header.g_gametype ?? header.gametype ?? null, gametype: info.g_gametype ?? header.g_gametype ?? header.gametype ?? null,
roster: parsePlayerRoster(text) roster: parsePlayerRoster(text)
}, }
rawReply: formatRawReply(send.buf)
}; };
} }
-1
View File
@@ -26,7 +26,6 @@ export interface ServerStatusEntry {
lastError: string | null; lastError: string | null;
endpoint: RconEndpoint | null; endpoint: RconEndpoint | null;
reason: RconFailure | null; reason: RconFailure | null;
replySnippet: string | null;
activeSince: string | null; activeSince: string | null;
inactiveSince: string | null; inactiveSince: string | null;
gameStartedAt: 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 type { PlayerCapacityObservation } from '../../server-capacity.js';
import { withHostRequestContext } from '../hosts/request-context.js'; import { withHostRequestContext } from '../hosts/request-context.js';
import { withOperationSource } from '../hosts/operation-context.js'; import { withOperationSource } from '../hosts/operation-context.js';
import { failureReplySnippet } from '../integrations/rcon-diagnostics.js';
const TICK_MS = 15_000; const TICK_MS = 15_000;
const RETENTION_INTERVAL_MS = 5 * 60_000; const RETENTION_INTERVAL_MS = 5 * 60_000;
@@ -227,7 +228,7 @@ async function refreshServerStatus(server: string, snapshot: MonitoringSnapshot,
gameObservation = probe; gameObservation = probe;
const now = probe.observedAt; const now = probe.observedAt;
updates.last_checked_at = now; updates.last_checked_at = now;
updates.last_reply_snippet = result.rawReply ?? null; updates.last_reply_snippet = failureReplySnippet(result);
if (result.ok) { if (result.ok) {
capacity = result.status.capacity ?? null; capacity = result.status.capacity ?? null;
Object.assign(updates, successfulStatusFields(result, now)); 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, return { server: srv.name, players: final.last_players, capacity: capacity?.capacity ?? null,
capacityObservedAt: capacity?.capacity_observed_at ?? null, map: final.last_map ?? srv.map, 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, 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, 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, activeSince: final.active_since, inactiveSince: final.inactive_since,
gameStartedAt: final.game_started_at, gameEndedAt: final.game_ended_at, 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, const base = status ? { ...server, last_players: status.players, last_map: status.map,
capacity: status.capacity ?? null, capacity_observed_at: status.capacityObservedAt ?? null, capacity: status.capacity ?? null, capacity_observed_at: status.capacityObservedAt ?? null,
last_gametype: status.gametype, last_seen_at: status.lastSeenAt, last_error: status.lastError, 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, inactive_since: status.inactiveSince, game_started_at: status.gameStartedAt,
game_ended_at: status.gameEndedAt, container_state: status.containerState ?? server.container_state, game_ended_at: status.gameEndedAt, container_state: status.containerState ?? server.container_state,
container_checked_at: status.containerCheckedAt ?? server.container_checked_at, container_checked_at: status.containerCheckedAt ?? server.container_checked_at,
-1
View File
@@ -35,7 +35,6 @@ export interface ServerStatus {
lastError: string | null; lastError: string | null;
endpoint: RconEndpoint | null; endpoint: RconEndpoint | null;
reason: RconFailure | null; reason: RconFailure | null;
replySnippet: string | null;
activeSince: string | null; activeSince: string | null;
inactiveSince: string | null; inactiveSince: string | null;
gameStartedAt: 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 { getServer, setServerField } from '$lib/server/fleet/servers.js';
import { latestGameSubmission } from '$lib/server/fleet/status-evidence.js'; import { latestGameSubmission } from '$lib/server/fleet/status-evidence.js';
import { isProvisioning } from '$lib/server/fleet/provisioning.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 * 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_checked_at', now);
setServerField(name, 'last_error', null); setServerField(name, 'last_error', null);
setServerField(name, 'rcon_failures', 0); setServerField(name, 'rcon_failures', 0);
setServerField(name, 'last_reply_snippet', result.rawReply); setServerField(name, 'last_reply_snippet', null);
} else { } else {
const encoded = `${result.reason}:${result.detail}`; const encoded = `${result.reason}:${result.detail}`;
setServerField(name, 'last_checked_at', now); setServerField(name, 'last_checked_at', now);
setServerField(name, 'last_error', encoded); setServerField(name, 'last_error', encoded);
// An explicitly requested diagnostic failure takes effect immediately. // An explicitly requested diagnostic failure takes effect immediately.
setServerField(name, 'rcon_failures', 2); setServerField(name, 'rcon_failures', 2);
setServerField(name, 'last_reply_snippet', result.rawReply ?? null); setServerField(name, 'last_reply_snippet', failureReplySnippet(result));
} }
return json(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_map: string | null; last_gametype: string | null;
last_seen_at: string | null; last_error: string | null; last_seen_at: string | null; last_error: string | null;
roster: RosterPlayer[] | null; roster: RosterPlayer[] | null;
health: Health; endpoint: RconEndpoint | null; replySnippet: string | null; health: Health; endpoint: RconEndpoint | null;
held: Array<{ target: string; betaId: number }>; held: Array<{ target: string; betaId: number }>;
active_since: string | null; inactive_since: string | null; active_since: string | null; inactive_since: string | null;
game_started_at: string | null; game_ended_at: string | null; game_started_at: string | null; game_ended_at: string | null;
@@ -108,6 +108,7 @@
let telemetryOpen = $state(false); let telemetryOpen = $state(false);
let inventory = $state<InventoryEntry[]>([]); let inventory = $state<InventoryEntry[]>([]);
let rconProbe = $state<RconProbeResult | null>(null); let rconProbe = $state<RconProbeResult | null>(null);
let savedRconSnippet = $state<string | null>(null);
let probingRcon = $state(false); let probingRcon = $state(false);
let destroyOpen = $state(false); let destroyOpen = $state(false);
const sectionIds = ['live', 'scoreboard', 'matches', 'chat', 'logs', 'configuration', 'config', 'inventory', 'diagnostics', 'history']; const sectionIds = ['live', 'scoreboard', 'matches', 'chat', 'logs', 'configuration', 'config', 'inventory', 'diagnostics', 'history'];
@@ -134,14 +135,17 @@
let tabSequence = 0; let tabSequence = 0;
async function loadTab(currentName: string, tab: string, signal?: AbortSignal) { async function loadTab(currentName: string, tab: string, signal?: AbortSignal) {
if (tab !== 'configuration') return; if (tab !== 'configuration' && (tab !== 'diagnostics' || !canOperate)) return;
const sequence = ++tabSequence; const sequence = ++tabSequence;
try { try {
const response = await fetch('/api/servers/' + encodeURIComponent(currentName) + '/scripts', { signal }); const response = await fetch('/api/servers/' + encodeURIComponent(currentName) + (tab === 'diagnostics' ? '/rcon-diagnostics' : '/scripts'), { signal });
if (!response.ok) throw new Error('Could not refresh inventory.'); if (!response.ok) throw new Error(tab === 'diagnostics' ? 'Could not refresh RCON diagnostics.' : 'Could not refresh inventory.');
const body = await response.json(); const body = await response.json();
if (sequence !== tabSequence || name !== currentName || activeSection !== tab || signal?.aborted) return; 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 = ''; tabError = '';
return true; return true;
} catch (error) { } catch (error) {
@@ -152,12 +156,12 @@
$effect(() => { $effect(() => {
const currentName = name; const currentName = name;
const tab = activeSection; const tab = activeSection;
tabError = ''; inventory = []; inventoryLimit = 25; tabError = ''; inventory = []; inventoryLimit = 25; savedRconSnippet = null;
tabLoading = tab === 'configuration'; tabLoading = tab === 'configuration' || (tab === 'diagnostics' && canOperate);
if (tab !== 'configuration') return; if (tab !== 'configuration' && (tab !== 'diagnostics' || !canOperate)) return;
return pollVisible(signal => loadTab(currentName, tab, signal), 10000); 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); const fresh = $derived(server ? freshServer(server, $uiNow) : false);
let effectivePlayers = $derived(fresh && health?.state === 'online' ? server?.last_players ?? null : null); 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 effectiveReason = $derived<RconFailure | null>(decoded?.reason ?? null);
let errorDetail = $derived(decoded?.detail ?? effectiveErr ?? ''); let errorDetail = $derived(decoded?.detail ?? effectiveErr ?? '');
let effectiveSnippet = $derived<string | null>( 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 effectiveActiveSince = $derived<string | null>(server?.active_since ?? null);
let effectiveInactiveSince = $derived<string | null>(server?.inactive_since ?? null); let effectiveInactiveSince = $derived<string | null>(server?.inactive_since ?? null);
@@ -195,14 +199,14 @@
return `${Math.floor(d / 86_400_000)}d ago`; return `${Math.floor(d / 86_400_000)}d ago`;
} }
async function probeRcon(): Promise<void> { async function probeRcon(): Promise<void> {
if (probingRcon || !name) return; if (!canOperate || probingRcon || !name) return;
const currentName = name; const currentName = name;
probingRcon = true; rconProbe = null; probingRcon = true; rconProbe = null;
try { try {
const res = await fetch(`/api/servers/${encodeURIComponent(currentName)}/probe-rcon`, { method: 'POST' }); const res = await fetch(`/api/servers/${encodeURIComponent(currentName)}/probe-rcon`, { method: 'POST' });
const body = await res.json().catch(() => null); 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 (!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; rconProbe = body as RconProbeResult;
await refreshFleet(); await refreshFleet();
} catch (err) { } catch (err) {
@@ -300,7 +304,7 @@
</div> </div>
<div class="connection-status"> <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} {#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><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> </div>
<p class="sub">Player count source: {fresh ? server.status_source === 'telemetry' ? 'game telemetry' : server.status_source === 'rcon' ? 'RCON' : 'unavailable' : 'unavailable'}.</p> <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> </div>
{/if} {/if}
{#if effectiveSnippet} {#if canOperate && effectiveSnippet}
<details class="snippet-details" open={effectiveReason === 'bad-reply'}> <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> <pre class="snippet">{effectiveSnippet}</pre>
</details> </details>
{/if} {/if}
+1 -1
View File
@@ -64,7 +64,7 @@ describe('management route permissions', () => {
'/configs/[id]', '/api/configs/[id]', '/api/configs/inspect', '/configs/[id]', '/api/configs/[id]', '/api/configs/inspect',
'/api/campaigns/[id]/file/[fileId]', '/api/deployments/[id]/file/[fileId]', '/api/campaigns/[id]/file/[fileId]', '/api/deployments/[id]/file/[fileId]',
'/api/betas/[id]/revisions/[revision]/files/[scriptId]', '/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 => { ])('prevents viewer access to secret-bearing raw content at %s', route => {
for (const method of ['GET', 'HEAD']) { for (const method of ['GET', 'HEAD']) {
expect(routePermission(route, method)).toBe('operator'); expect(routePermission(route, method)).toBe('operator');
+3
View File
@@ -131,6 +131,9 @@ describe('browser authentication guard', () => {
it.each([ it.each([
['viewer', '/api/fleet', 'GET', 200], ['viewer', '/api/servers', 'POST', 403], ['viewer', '/api/fleet', 'GET', 200], ['viewer', '/api/servers', 'POST', 403],
['viewer', '/api/configs/[id]', 'GET', 403], ['operator', '/api/configs/[id]', 'GET', 200], ['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/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] ['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) => { ] as const)('enforces %s role for %s %s', async (role, routeId, method, expectedStatus) => {
+1 -1
View File
@@ -44,7 +44,7 @@ beforeEach(async () => {
await seedServerRegistry(); await seedServerRegistry();
const { rconGetPlayerCount, rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js'); const { rconGetPlayerCount, rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js');
vi.mocked(rconGetPlayerCount).mockResolvedValue(0); 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(); }); afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); vi.useRealTimers(); });
+1 -1
View File
@@ -6,7 +6,7 @@ import type { CollectionMode } from '../../src/lib/deployment-plan.js';
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
rconGetPlayerCount: vi.fn(async () => 0), rconGetPlayerCount: vi.fn(async () => 0),
rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null })), 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: [] } })) status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } }))
})); }));
vi.mock('../../src/lib/server/integrations/plutonium.js', () => ({ vi.mock('../../src/lib/server/integrations/plutonium.js', () => ({
@@ -6,7 +6,7 @@ import { newFixture, type Fixture } from '../helpers/fixture.js';
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
rconGetPlayerCount: vi.fn(async () => 0), rconGetPlayerCount: vi.fn(async () => 0),
rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null })), 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: [] } })) status: { players: 0, map: 'zm_transit', gametype: null, roster: [] } }))
})); }));
+1 -1
View File
@@ -12,7 +12,7 @@ vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
await new Promise(resolve => setTimeout(resolve, 2)); await new Promise(resolve => setTimeout(resolve, 2));
return { revision: 5354, error: null }; 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: [] } })) 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 () => {}) })); vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}), kickProvisioning: vi.fn(async () => {}) }));
+1 -1
View File
@@ -7,7 +7,7 @@ vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ rconStatusResult:
const server = 'managed'; const server = 'managed';
const now = Date.parse('2026-10-05T16:00:00Z'); const now = Date.parse('2026-10-05T16:00:00Z');
const healthy = { ok: true as const, state: 'running' }; 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: [] } }; status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } };
let fixture: Fixture; let fixture: Fixture;
+1 -1
View File
@@ -7,7 +7,7 @@ import { newFixture, type Fixture } from '../helpers/fixture.js';
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
rconGetPlayerCount: vi.fn(async () => 0), rconGetPlayerCount: vi.fn(async () => 0),
rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null })), 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: [] } })) 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 () => {}) })); vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}), kickProvisioning: vi.fn(async () => {}) }));
+3 -3
View File
@@ -6,7 +6,7 @@ import { newFixture, type Fixture } from '../helpers/fixture.js';
const remote = vi.hoisted(() => ({ state: 'running', players: 0, map: 'zombie_theater' })); const remote = vi.hoisted(() => ({ state: 'running', players: 0, map: 'zombie_theater' }));
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ 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 })) 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) })); 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 observedAt = new Date().toISOString();
const roster = [{ num: 0, name: 'Player', guid: '12345678901234567', score: 500, ping: 20 }]; const roster = [{ num: 0, name: 'Player', guid: '12345678901234567', score: 500, ping: 20 }];
const { rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js'); 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 } }); status: { players: 1, map: remote.map, gametype: 'zom', roster } });
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js'); const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
const { getDeployment } = await import('../../src/lib/server/provisioning/history.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((await detail(id)).state).toBe('done');
expect(visible).toEqual(['online']); expect(visible).toEqual(['online']);
expect(getServer(job.name)).toMatchObject({ last_checked_at: observedAt, last_seen_at: observedAt, 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 }); 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 }); for (const status of await observations(job.name)) expect(status).toMatchObject({ health: 'online', last_players: 1, roster });
}); });
+1 -1
View File
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js'; import { newFixture, type Fixture } from '../helpers/fixture.js';
vi.mock('../../src/lib/server/integrations/rcon.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 })) 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 () => {}) })); vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}), kickProvisioning: vi.fn(async () => {}) }));
+1 -1
View File
@@ -24,7 +24,7 @@ beforeEach(async () => {
const r = await import('../../src/lib/server/integrations/rcon.js'); const r = await import('../../src/lib/server/integrations/rcon.js');
vi.mocked(r.rconGetPlayerCount).mockResolvedValue(0); vi.mocked(r.rconGetPlayerCount).mockResolvedValue(0);
vi.mocked(r.rconPlutoniumVersion).mockResolvedValue({ revision: 5354, error: null }); 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(); }); afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); vi.useRealTimers(); });
const api = () => import('../../src/lib/server/fleet/plutonium.js'); const api = () => import('../../src/lib/server/fleet/plutonium.js');
+84
View File
@@ -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' });
});
+15
View File
@@ -48,6 +48,20 @@ afterEach(() => {
}); });
describe('RCON status transport', () => { 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([ it.each([
['timeout', 'timeout', 'Agent received no reply'], ['timeout', 'timeout', 'Agent received no reply'],
['unreachable', 'send-error', 'Agent could not reach'], ['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'); expect(sockets[0].send.mock.calls[0][0].toString()).toContain('rcon test status\n');
reply(); reply();
expect(await health).toMatchObject({ ok: true, status: { players: 1, map: 'mp_kino' } }); 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 occupancy).toBe(1);
expect(await duplicate).toEqual(await health); expect(await duplicate).toEqual(await health);
expect(sockets[0].close).toHaveBeenCalledOnce(); expect(sockets[0].close).toHaveBeenCalledOnce();
+33 -1
View File
@@ -4,8 +4,40 @@ import {
parsePlayerCount, parsePlayerCount,
parsePlayerRoster, parsePlayerRoster,
parseStatusHeader, parseStatusHeader,
formatRawReply formatRawReply,
agentRconStatusResult
} from '../../src/lib/server/integrations/rcon.js'; } 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', () => { describe('parsePlayerCount', () => {
it('counts rows after --- --- separator (status dialect)', () => { it('counts rows after --- --- separator (status dialect)', () => {
+1 -1
View File
@@ -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/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/plutonium.js', () => ({ checkRelease: vi.fn(async () => {}), freshRelease: () => 5354, probeVersion: vi.fn(async () => 5354) }));
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ 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: [] } })), status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } })),
rconGetPlayerCount: vi.fn(async () => 0), rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null })) rconGetPlayerCount: vi.fn(async () => 0), rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null }))
})); }));
+1 -1
View File
@@ -24,7 +24,7 @@ async function setup(count = 1) {
const { monitoringStatusWriter } = await import('../../src/lib/server/fleet/health-observations.js'); const { monitoringStatusWriter } = await import('../../src/lib/server/fleet/health-observations.js');
function publish(name: string, capacity: number | null | undefined = 4, observedAt = iso(), ok = true) { function publish(name: string, capacity: number | null | undefined = 4, observedAt = iso(), ok = true) {
monitoringStatusWriter(name)({ observedAt: new Date().toISOString(), result: { ok: true, state: 'running' } }, 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: [] } } status: { players: 3, capacity, map: 'zombie_theater', gametype: 'zom', roster: [] } }
: { ok: false, endpoint: null, reason: 'timeout', detail: 'No reply' } }); : { ok: false, endpoint: null, reason: 'timeout', detail: 'No reply' } });
} }
+35 -19
View File
@@ -28,6 +28,22 @@ async function setup() {
} }
describe('database fleet and container readiness', () => { 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 () => { it('publishes capacity from accepted monitoring samples and clears it on the next failed check', async () => {
const { inspect, rcon, fanoutStatus } = await setup(); const { inspect, rcon, fanoutStatus } = await setup();
const { GET } = await import('../../src/routes/api/servers/+server.js'); 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'); const at = Date.parse('2026-10-07T12:00:00Z');
vi.setSystemTime(at); vi.setSystemTime(at);
inspect.mockResolvedValue({ ok: true, state: 'running' }); 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: [] } }); status: { players: 3, capacity: 4, map: 'zombie_theater', gametype: 'zom', roster: [] } });
const events: unknown[] = []; const events: unknown[] = [];
const stop = subscribe(event => { if (event.type === 'server-status') events.push(event); }); 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'); const at = Date.parse('2026-10-05T12:00:00Z');
vi.setSystemTime(at); vi.setSystemTime(at);
inspect.mockResolvedValue({ ok: true, state: 'running' }); 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: [] } }; status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } };
rcon.mockResolvedValue(success); rcon.mockResolvedValue(success);
await fanoutStatus(); await fanoutStatus();
@@ -130,7 +146,7 @@ describe('database fleet and container readiness', () => {
const at = Date.parse('2026-10-05T12:00:00Z'); const at = Date.parse('2026-10-05T12:00:00Z');
vi.setSystemTime(at); vi.setSystemTime(at);
inspect.mockResolvedValue({ ok: true, state: 'running' }); 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: [] } }); status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } });
await inspectForMonitoring('managed'); await inspectForMonitoring('managed');
await rconForMonitoring('managed'); await rconForMonitoring('managed');
@@ -162,7 +178,7 @@ describe('database fleet and container readiness', () => {
await vi.waitFor(() => expect(rcon).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(rcon).toHaveBeenCalledTimes(1));
vi.setSystemTime(at + 10_000); 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()); 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: [] } }); status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } });
await pending; await pending;
expect(getServer('managed')).toMatchObject({ last_error: 'timeout:manual failure', last_seen_at: null, last_checked_at: new Date(at + 10_000).toISOString() }); 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' }; : { ok: true as const, state: 'running' };
} finally { active--; } } 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: [] } }); status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } });
const events: unknown[] = []; const events: unknown[] = [];
const unsubscribe = subscribe(event => events.push(event)); const unsubscribe = subscribe(event => events.push(event));
@@ -244,7 +260,7 @@ describe('database fleet and container readiness', () => {
try { try {
await (host === 'host-0' ? slow : fast); await (host === 'host-0' ? slow : fast);
if (name === 'host-0-0') throw new Error('simulated failed probe'); 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: [] } }; status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } };
} finally { active.set(host, active.get(host)! - 1); } } finally { active.set(host, active.get(host)! - 1); }
}); });
@@ -283,7 +299,7 @@ describe('database fleet and container readiness', () => {
inspect.mockImplementation(async name => name === 'slow' inspect.mockImplementation(async name => name === 'slow'
? await new Promise<{ ok: true; state: string }>(resolve => { release = resolve; }) ? await new Promise<{ ok: true; state: string }>(resolve => { release = resolve; })
: { ok: true as const, state: 'running' }); : { 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: [] } }); status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } });
const pending = fanoutStatus(); const pending = fanoutStatus();
try { try {
@@ -305,7 +321,7 @@ describe('database fleet and container readiness', () => {
rcon.mockImplementation(() => new Promise(resolve => { release = resolve; })); rcon.mockImplementation(() => new Promise(resolve => { release = resolve; }));
const pending = POST({ params: { name: 'managed' } } as never); const pending = POST({ params: { name: 'managed' } } as never);
db.prepare("INSERT INTO restart_rollout (server, requested_at) VALUES ('managed', ?)").run(new Date().toISOString()); 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: [] } }); status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } });
expect(await (await pending).json()).toMatchObject({ ok: true }); expect(await (await pending).json()).toMatchObject({ ok: true });
expect(getServer('managed')).toMatchObject({ last_seen_at: null, last_error: 'timeout:old process' }); 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 () => { rcon.mockImplementation(async () => {
if (phase === 'rcon') await blocked; 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: [] } }; status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } };
}); });
const pending = fanoutStatus(); const pending = fanoutStatus();
@@ -346,7 +362,7 @@ describe('database fleet and container readiness', () => {
expect(inspect).not.toHaveBeenCalled(); expect(inspect).not.toHaveBeenCalled();
expect(rcon).not.toHaveBeenCalled(); expect(rcon).not.toHaveBeenCalled();
expect(events).toContainEqual(expect.objectContaining({ type: 'server-status', entries: [expect.objectContaining({ 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(); db.prepare("UPDATE server_deployments SET state = 'failed' WHERE id = 'creation'").run();
inspect.mockResolvedValue({ ok: true, state: 'running' }); inspect.mockResolvedValue({ ok: true, state: 'running' });
@@ -354,7 +370,7 @@ describe('database fleet and container readiness', () => {
await fanoutStatus(); await fanoutStatus();
expect(await snapshot()).toMatchObject({ health: 'offline', last_error: 'timeout:no reply' }); expect(await snapshot()).toMatchObject({ health: 'offline', last_error: 'timeout:no reply' });
db.prepare("UPDATE server_deployments SET state = 'done' WHERE id = 'creation'").run(); 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: [] } }); status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } });
await fanoutStatus(); await fanoutStatus();
expect(await snapshot()).toMatchObject({ health: 'online', last_error: null }); 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 () => { it('keeps game_end through occupied polls until game_start or an empty lobby', async () => {
const { inspect, rcon, fanoutStatus, getServer } = await setup(); const { inspect, rcon, fanoutStatus, getServer } = await setup();
inspect.mockResolvedValue({ ok: true, state: 'running' }); 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: [] } }); status: { players, map: 'zombie_theater', gametype: 'zom', roster: [] } });
rcon.mockResolvedValue(reply(1)); rcon.mockResolvedValue(reply(1));
await postTelemetry(gameEvent('game_start', 0, 1, 'managed')); await postTelemetry(gameEvent('game_start', 0, 1, 'managed'));
@@ -396,7 +412,7 @@ describe('database fleet and container readiness', () => {
vi.useFakeTimers({ toFake: ['Date'] }); vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(startedAt); vi.setSystemTime(startedAt);
inspect.mockResolvedValue({ ok: true, state: 'running' }); 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: [] } }); status: { players: 1, map: 'zombie_theater', gametype: 'zom', roster: [] } });
expect((await postTelemetry(gameEvent('game_start', 0, 1, 'managed'))).status).toBe(200); 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) }); 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.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(startedAt - 20_000); vi.setSystemTime(startedAt - 20_000);
inspect.mockResolvedValue({ ok: true, state: 'running' }); 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: [] } }); status: { players: 1, map: 'zombie_theater', gametype: 'zom', roster: [] } });
await fanoutStatus(); await fanoutStatus();
expect(getServer('managed')).toMatchObject({ active_since: at(-20), game_started_at: null }); 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 mkdir(logs, { recursive: true });
await writeFile(join(logs, 'games_sp.log'), '0:00 InitGame:\n0:01 J;123;0;Player\n'); await writeFile(join(logs, 'games_sp.log'), '0:00 InitGame:\n0:01 J;123;0;Player\n');
inspect.mockResolvedValue({ ok: true, state: 'running' }); 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: [] } }); status: { players: 1, map: 'zombie_theater', gametype: 'zom', roster: [] } });
await fanoutStatus(); await fanoutStatus();
expect(getServer('managed')).toMatchObject({ active_since: expect.any(String), game_started_at: null }); 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); const telemetry = gameEvent('heartbeat', 15, 3, 'managed', map);
telemetry.players[0].ping = 42; telemetry.players[0].ping = 42;
telemetry.players[1].guid = '7097376'; 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: [ status: { players: 4, map, gametype: 'zom', roster: [
{ num: 0, guid: telemetry.players[0].guid, name: 'Old name', score: 50, ping: 100 }, { 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 }, { 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' }); inspect.mockResolvedValue({ ok: true, state: 'running' });
await postTelemetry(gameEvent('heartbeat', 15, 3, 'managed')); await postTelemetry(gameEvent('heartbeat', 15, 3, 'managed'));
db.prepare('UPDATE server_telemetry SET received_at = ?').run(new Date(Date.now() - 91_000).toISOString()); 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: [] } }); status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } });
await fanoutStatus(); await fanoutStatus();
expect(rcon).toHaveBeenCalledTimes(1); expect(rcon).toHaveBeenCalledTimes(1);
@@ -605,7 +621,7 @@ describe('database fleet and container readiness', () => {
expect(rcon).toHaveBeenCalledTimes(1); expect(rcon).toHaveBeenCalledTimes(1);
expect(getServer('managed')).toMatchObject({ container_state: 'running', last_error: 'timeout:no reply' }); 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: [] } }); status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } });
await fanoutStatus(); await fanoutStatus();
expect((await (await GET()).json()).servers[0]).toMatchObject({ health: 'online', last_error: null }); 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')); if (state === 'throw') inspect.mockRejectedValue(new Error('Inspection timed out'));
else inspect.mockResolvedValue({ ok: false, state, reason: 'Inspection timed out' }); else inspect.mockResolvedValue({ ok: false, state, reason: 'Inspection timed out' });
await postTelemetry(gameEvent('heartbeat', 15, 3, 'managed')); 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: [] } }); status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } });
const events: unknown[] = []; const events: unknown[] = [];
const unsubscribe = subscribe(event => events.push(event)); const unsubscribe = subscribe(event => events.push(event));
+1 -1
View File
@@ -38,7 +38,7 @@ beforeEach(async () => {
const { rconGetPlayerCount, rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js'); const { rconGetPlayerCount, rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js');
vi.mocked(rconGetPlayerCount).mockResolvedValue(0); vi.mocked(rconGetPlayerCount).mockResolvedValue(0);
vi.mocked(rconStatusResult).mockResolvedValue({ 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: [] } status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] }
}); });
}); });
+1 -1
View File
@@ -34,7 +34,7 @@ function status(timestamp: number, changes: Partial<ServerStatus> = {}): ServerS
return { return {
server: 't5_zombie_theater_1', players: 0, map: 'zombie_theater', gametype: 'zom', server: 't5_zombie_theater_1', players: 0, map: 'zombie_theater', gametype: 'zom',
roster: [], health: 'online', lastSeenAt: new Date(timestamp).toISOString(), 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(), activeSince: null, inactiveSince: new Date(epoch).toISOString(),
gameStartedAt: null, gameEndedAt: null, ...changes gameStartedAt: null, gameEndedAt: null, ...changes
}; };
+12 -3
View File
@@ -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'); expect(body).toContain('Live files and active betas may differ');
}); });
function pageBody(tab: string) { function pageBody(tab: string, role = 'admin') {
(page as unknown as ReturnType<typeof writable>).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`) }); (page as unknown as ReturnType<typeof writable>).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 telemetry = report();
const server = { name: 't5_zombie_theater_1', game: 't5', map: 'zombie_theater', last_map: 'zombie_theater', health: 'online', last_players: 1, 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', 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 }; 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<typeof ServerPage>['data']; const data = { serverSnapshot: { servers: [server], operations: [], serverNow: get(uiNow), integrations: {}, reservedNames: [] } } as unknown as ComponentProps<typeof ServerPage>['data'];
return render(ServerPage, { props: { data } }).body; 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).toContain('Connection details');
expect(body).not.toContain('Telemetry API requests'); expect(body).not.toContain('Telemetry API requests');
expect(body).not.toContain('>Healthy<'); 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', () => { it('uses the shared Activity view for server history', () => {
+1 -1
View File
@@ -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 () => {}) })); vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) }));
const server = 't5_zombie_theater_1'; 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: [] } }; 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' }; const missingRcon = { ok: false as const, endpoint: { host: 'test', port: 28960 }, reason: 'timeout' as const, detail: 'No reply' };
let fixture: Fixture; let fixture: Fixture;