Compare commits
2
Commits
d8fb7e1d66
...
0cc4498b53
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cc4498b53 | ||
|
|
29d2d9634c |
@@ -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`).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' } });
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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(); });
|
||||||
|
|
||||||
|
|||||||
@@ -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: [] } }))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {}) }));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {}) }));
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {}) }));
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
});
|
||||||
@@ -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
@@ -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)', () => {
|
||||||
|
|||||||
@@ -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 }))
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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' } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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: [] }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user