Protect RCON diagnostics and redact player addresses (Refs #61)
This commit is contained in:
@@ -64,7 +64,7 @@ describe('management route permissions', () => {
|
||||
'/configs/[id]', '/api/configs/[id]', '/api/configs/inspect',
|
||||
'/api/campaigns/[id]/file/[fileId]', '/api/deployments/[id]/file/[fileId]',
|
||||
'/api/betas/[id]/revisions/[revision]/files/[scriptId]',
|
||||
'/api/servers/[name]/logs/[file]', '/api/servers/[name]/telemetry-requests'
|
||||
'/api/servers/[name]/logs/[file]', '/api/servers/[name]/telemetry-requests', '/api/servers/[name]/rcon-diagnostics'
|
||||
])('prevents viewer access to secret-bearing raw content at %s', route => {
|
||||
for (const method of ['GET', 'HEAD']) {
|
||||
expect(routePermission(route, method)).toBe('operator');
|
||||
|
||||
@@ -131,6 +131,9 @@ describe('browser authentication guard', () => {
|
||||
it.each([
|
||||
['viewer', '/api/fleet', 'GET', 200], ['viewer', '/api/servers', 'POST', 403],
|
||||
['viewer', '/api/configs/[id]', 'GET', 403], ['operator', '/api/configs/[id]', 'GET', 200],
|
||||
['viewer', '/api/servers/[name]/rcon-diagnostics', 'GET', 403],
|
||||
['operator', '/api/servers/[name]/rcon-diagnostics', 'GET', 200],
|
||||
['admin', '/api/servers/[name]/rcon-diagnostics', 'GET', 200],
|
||||
['operator', '/api/provisioning', 'POST', 200], ['operator', '/api/hosts/[id]', 'PATCH', 403],
|
||||
['operator', '/api/users', 'GET', 403], ['admin', '/api/hosts/[id]', 'PATCH', 200], ['admin', '/api/users', 'GET', 200]
|
||||
] as const)('enforces %s role for %s %s', async (role, routeId, method, expectedStatus) => {
|
||||
|
||||
@@ -44,7 +44,7 @@ beforeEach(async () => {
|
||||
await seedServerRegistry();
|
||||
const { rconGetPlayerCount, rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js');
|
||||
vi.mocked(rconGetPlayerCount).mockResolvedValue(0);
|
||||
vi.mocked(rconStatusResult).mockResolvedValue({ ok: true, endpoint: { host: 'test', port: 1 }, rawReply: '', status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } });
|
||||
vi.mocked(rconStatusResult).mockResolvedValue({ ok: true, endpoint: { host: 'test', port: 1 }, status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } });
|
||||
});
|
||||
afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); vi.useRealTimers(); });
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { CollectionMode } from '../../src/lib/deployment-plan.js';
|
||||
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
|
||||
rconGetPlayerCount: vi.fn(async () => 0),
|
||||
rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null })),
|
||||
rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 }, rawReply: '',
|
||||
rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 },
|
||||
status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } }))
|
||||
}));
|
||||
vi.mock('../../src/lib/server/integrations/plutonium.js', () => ({
|
||||
|
||||
@@ -6,7 +6,7 @@ import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
|
||||
rconGetPlayerCount: vi.fn(async () => 0),
|
||||
rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null })),
|
||||
rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 }, rawReply: '',
|
||||
rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 },
|
||||
status: { players: 0, map: 'zm_transit', gametype: null, roster: [] } }))
|
||||
}));
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
|
||||
await new Promise(resolve => setTimeout(resolve, 2));
|
||||
return { revision: 5354, error: null };
|
||||
}),
|
||||
rconStatusResult: vi.fn(async (server: string) => ({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: '',
|
||||
rconStatusResult: vi.fn(async (server: string) => ({ ok: true, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players: 0, map: server.startsWith('t6_') ? 'zm_transit' : server.includes('_moon_') ? 'zombie_moon' : 'zombie_theater', gametype: null, roster: [] } }))
|
||||
}));
|
||||
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}), kickProvisioning: vi.fn(async () => {}) }));
|
||||
|
||||
@@ -7,7 +7,7 @@ vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ rconStatusResult:
|
||||
const server = 'managed';
|
||||
const now = Date.parse('2026-10-05T16:00:00Z');
|
||||
const healthy = { ok: true as const, state: 'running' };
|
||||
const playing = { ok: true as const, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
const playing = { ok: true as const, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } };
|
||||
let fixture: Fixture;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
|
||||
rconGetPlayerCount: vi.fn(async () => 0),
|
||||
rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null })),
|
||||
rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: '',
|
||||
rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players: 0, map: 'zombie_theater', gametype: null, roster: [] } }))
|
||||
}));
|
||||
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}), kickProvisioning: vi.fn(async () => {}) }));
|
||||
|
||||
@@ -6,7 +6,7 @@ import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
const remote = vi.hoisted(() => ({ state: 'running', players: 0, map: 'zombie_theater' }));
|
||||
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
|
||||
rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 }, rawReply: '', status: { players: remote.players, map: remote.map, gametype: 'zom', roster: [] } })),
|
||||
rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 }, status: { players: remote.players, map: remote.map, gametype: 'zom', roster: [] } })),
|
||||
rconGetPlayerCount: vi.fn(async () => remote.players), rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null }))
|
||||
}));
|
||||
vi.mock('../../src/lib/server/integrations/plutonium.js', () => ({ checkRelease: vi.fn(async () => {}), freshRelease: () => 5354, probeVersion: vi.fn(async () => 5354) }));
|
||||
@@ -261,7 +261,7 @@ describe('provisioning health handoff', () => {
|
||||
const observedAt = new Date().toISOString();
|
||||
const roster = [{ num: 0, name: 'Player', guid: '12345678901234567', score: 500, ping: 20 }];
|
||||
const { rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js');
|
||||
vi.mocked(rconStatusResult).mockResolvedValueOnce({ ok: true, endpoint: { host: 'agent', port: 28960 }, rawReply: 'verified status',
|
||||
vi.mocked(rconStatusResult).mockResolvedValueOnce({ ok: true, endpoint: { host: 'agent', port: 28960 },
|
||||
status: { players: 1, map: remote.map, gametype: 'zom', roster } });
|
||||
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
|
||||
const { getDeployment } = await import('../../src/lib/server/provisioning/history.js');
|
||||
@@ -276,7 +276,7 @@ describe('provisioning health handoff', () => {
|
||||
expect((await detail(id)).state).toBe('done');
|
||||
expect(visible).toEqual(['online']);
|
||||
expect(getServer(job.name)).toMatchObject({ last_checked_at: observedAt, last_seen_at: observedAt,
|
||||
last_players: 1, last_roster: JSON.stringify(roster), last_reply_snippet: 'verified status',
|
||||
last_players: 1, last_roster: JSON.stringify(roster), last_reply_snippet: null,
|
||||
last_error: null, rcon_failures: 0, active_since: observedAt, inactive_since: null });
|
||||
for (const status of await observations(job.name)) expect(status).toMatchObject({ health: 'online', last_players: 1, roster });
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
|
||||
rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 }, rawReply: '', status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } })),
|
||||
rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 }, status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } })),
|
||||
rconGetPlayerCount: vi.fn(async () => 0), rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null }))
|
||||
}));
|
||||
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}), kickProvisioning: vi.fn(async () => {}) }));
|
||||
|
||||
@@ -24,7 +24,7 @@ beforeEach(async () => {
|
||||
const r = await import('../../src/lib/server/integrations/rcon.js');
|
||||
vi.mocked(r.rconGetPlayerCount).mockResolvedValue(0);
|
||||
vi.mocked(r.rconPlutoniumVersion).mockResolvedValue({ revision: 5354, error: null });
|
||||
vi.mocked(r.rconStatusResult).mockResolvedValue({ ok: true, endpoint: { host: 'test', port: 28960 }, rawReply: '', status: { players: 0, map: null, gametype: null, roster: [] } });
|
||||
vi.mocked(r.rconStatusResult).mockResolvedValue({ ok: true, endpoint: { host: 'test', port: 28960 }, status: { players: 0, map: null, gametype: null, roster: [] } });
|
||||
});
|
||||
afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); vi.useRealTimers(); });
|
||||
const api = () => import('../../src/lib/server/fleet/plutonium.js');
|
||||
|
||||
@@ -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', () => {
|
||||
it('keeps reply bodies out of public failure summaries and redacts UDP diagnostics', async () => {
|
||||
const request = rconStatusResult(server.name);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
sockets[0].emit('message', packet('print\nplayer 192.0.2.123:28960 unexpected response'));
|
||||
const result = await request;
|
||||
expect(result).toMatchObject({ ok: false, reason: 'bad-reply' });
|
||||
if (result.ok) throw new Error('Expected an unrecognized reply');
|
||||
expect(result.detail).not.toContain('player');
|
||||
expect(result.detail).not.toContain('192.0.2');
|
||||
const hex = result.rawReply!.split('\n').map(line => line.slice(6, 53).trim()).join(' ').replaceAll(' ', '');
|
||||
const decoded = Buffer.from(hex, 'hex').toString();
|
||||
expect(decoded).toContain('[redacted]');
|
||||
expect(decoded).not.toContain('192.0.2');
|
||||
});
|
||||
it.each([
|
||||
['timeout', 'timeout', 'Agent received no reply'],
|
||||
['unreachable', 'send-error', 'Agent could not reach'],
|
||||
@@ -79,6 +93,7 @@ describe('RCON status transport', () => {
|
||||
expect(sockets[0].send.mock.calls[0][0].toString()).toContain('rcon test status\n');
|
||||
reply();
|
||||
expect(await health).toMatchObject({ ok: true, status: { players: 1, map: 'mp_kino' } });
|
||||
expect(await health).not.toHaveProperty('rawReply');
|
||||
expect(await occupancy).toBe(1);
|
||||
expect(await duplicate).toEqual(await health);
|
||||
expect(sockets[0].close).toHaveBeenCalledOnce();
|
||||
|
||||
+33
-1
@@ -4,8 +4,40 @@ import {
|
||||
parsePlayerCount,
|
||||
parsePlayerRoster,
|
||||
parseStatusHeader,
|
||||
formatRawReply
|
||||
formatRawReply,
|
||||
agentRconStatusResult
|
||||
} from '../../src/lib/server/integrations/rcon.js';
|
||||
import { redactRconAddresses } from '../../src/lib/server/integrations/rcon-diagnostics.js';
|
||||
|
||||
describe('private RCON diagnostics', () => {
|
||||
const addresses = ['192.0.2.123:28960', '198.51.100.9', '[2001:db8::1]:28960',
|
||||
'2001:db8::abcd', '[::ffff:192.0.2.4]:28960', '[fe80::1%eth0]:1234', '::1'];
|
||||
|
||||
it.each(addresses)('redacts %s from both plain text and decoded hex diagnostics', address => {
|
||||
const text = `unrecognized status\nplayer address ${address} trailing text`;
|
||||
expect(redactRconAddresses(text)).toBe('unrecognized status\nplayer address [redacted] trailing text');
|
||||
const dump = formatRawReply(Buffer.from(text));
|
||||
const hex = dump.split('\n').map(line => line.slice(6, 53).trim()).join(' ').replaceAll(' ', '');
|
||||
const decoded = Buffer.from(hex, 'hex').toString();
|
||||
expect(decoded).toBe(redactRconAddresses(text));
|
||||
});
|
||||
|
||||
it('redacts complete addresses before truncating native and UDP replies', () => {
|
||||
const native = agentRconStatusResult({ text: 'x'.repeat(3990) + ' 192.0.2.123:28960' }, { host: 'agent', port: 1 });
|
||||
expect(native).toMatchObject({ ok: false });
|
||||
expect(native.rawReply).not.toContain('192.0.2');
|
||||
expect(native.rawReply!.length).toBeLessThanOrEqual(4000);
|
||||
const dump = formatRawReply(Buffer.from('x'.repeat(500) + ' 192.0.2.123:28960'));
|
||||
const hex = dump.split('\n').filter(line => /^[0-9a-f]{4} /.test(line)).map(line => line.slice(6, 53).trim()).join(' ').replaceAll(' ', '');
|
||||
expect(Buffer.from(hex, 'hex').toString()).not.toContain('192.0.2');
|
||||
});
|
||||
|
||||
it('omits native success replies while preserving the parsed status', () => {
|
||||
const result = agentRconStatusResult({ text: 'print\nmap: mp_kino\nnum score ping guid name address\n--- ----- ---- ---- ---- -------\n' }, { host: 'agent', port: 1 });
|
||||
expect(result).toMatchObject({ ok: true, status: { players: 0, map: 'mp_kino' } });
|
||||
expect(result).not.toHaveProperty('rawReply');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePlayerCount', () => {
|
||||
it('counts rows after --- --- separator (status dialect)', () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { RebalanceMove } from '../../src/lib/rebalancing.js';
|
||||
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}), kickProvisioning: vi.fn(async () => {}) }));
|
||||
vi.mock('../../src/lib/server/integrations/plutonium.js', () => ({ checkRelease: vi.fn(async () => {}), freshRelease: () => 5354, probeVersion: vi.fn(async () => 5354) }));
|
||||
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
|
||||
rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 }, rawReply: '',
|
||||
rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 },
|
||||
status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } })),
|
||||
rconGetPlayerCount: vi.fn(async () => 0), rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null }))
|
||||
}));
|
||||
|
||||
@@ -24,7 +24,7 @@ async function setup(count = 1) {
|
||||
const { monitoringStatusWriter } = await import('../../src/lib/server/fleet/health-observations.js');
|
||||
function publish(name: string, capacity: number | null | undefined = 4, observedAt = iso(), ok = true) {
|
||||
monitoringStatusWriter(name)({ observedAt: new Date().toISOString(), result: { ok: true, state: 'running' } },
|
||||
{ observedAt, result: ok ? { ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
{ observedAt, result: ok ? { ok: true, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players: 3, capacity, map: 'zombie_theater', gametype: 'zom', roster: [] } }
|
||||
: { ok: false, endpoint: null, reason: 'timeout', detail: 'No reply' } });
|
||||
}
|
||||
|
||||
@@ -28,6 +28,22 @@ async function setup() {
|
||||
}
|
||||
|
||||
describe('database fleet and container readiness', () => {
|
||||
it('retains only sanitized failed replies and clears them after a successful monitoring check', async () => {
|
||||
const { inspect, rcon, fanoutStatus, getServer } = await setup();
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
const at = Date.parse('2026-10-08T12:00:00Z');
|
||||
vi.setSystemTime(at);
|
||||
inspect.mockResolvedValue({ ok: true, state: 'running' });
|
||||
rcon.mockResolvedValue({ ok: false, endpoint: null, reason: 'bad-reply', detail: 'unrecognized status',
|
||||
rawReply: 'player 192.0.2.123:28960 invalid reply' });
|
||||
await fanoutStatus();
|
||||
expect(getServer('managed')?.last_reply_snippet).toBe('player [redacted] invalid reply');
|
||||
vi.setSystemTime(at + 30_000);
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'agent', port: 1 },
|
||||
status: { players: 0, map: null, gametype: null, roster: [] } });
|
||||
await fanoutStatus();
|
||||
expect(getServer('managed')?.last_reply_snippet).toBeNull();
|
||||
});
|
||||
it('publishes capacity from accepted monitoring samples and clears it on the next failed check', async () => {
|
||||
const { inspect, rcon, fanoutStatus } = await setup();
|
||||
const { GET } = await import('../../src/routes/api/servers/+server.js');
|
||||
@@ -36,7 +52,7 @@ describe('database fleet and container readiness', () => {
|
||||
const at = Date.parse('2026-10-07T12:00:00Z');
|
||||
vi.setSystemTime(at);
|
||||
inspect.mockResolvedValue({ ok: true, state: 'running' });
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players: 3, capacity: 4, map: 'zombie_theater', gametype: 'zom', roster: [] } });
|
||||
const events: unknown[] = [];
|
||||
const stop = subscribe(event => { if (event.type === 'server-status') events.push(event); });
|
||||
@@ -61,7 +77,7 @@ describe('database fleet and container readiness', () => {
|
||||
const at = Date.parse('2026-10-05T12:00:00Z');
|
||||
vi.setSystemTime(at);
|
||||
inspect.mockResolvedValue({ ok: true, state: 'running' });
|
||||
const success = { ok: true as const, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
const success = { ok: true as const, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } };
|
||||
rcon.mockResolvedValue(success);
|
||||
await fanoutStatus();
|
||||
@@ -130,7 +146,7 @@ describe('database fleet and container readiness', () => {
|
||||
const at = Date.parse('2026-10-05T12:00:00Z');
|
||||
vi.setSystemTime(at);
|
||||
inspect.mockResolvedValue({ ok: true, state: 'running' });
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } });
|
||||
await inspectForMonitoring('managed');
|
||||
await rconForMonitoring('managed');
|
||||
@@ -162,7 +178,7 @@ describe('database fleet and container readiness', () => {
|
||||
await vi.waitFor(() => expect(rcon).toHaveBeenCalledTimes(1));
|
||||
vi.setSystemTime(at + 10_000);
|
||||
db.prepare("UPDATE servers SET last_checked_at=?,last_error='timeout:manual failure' WHERE name='managed'").run(new Date().toISOString());
|
||||
release({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'old reply',
|
||||
release({ ok: true, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } });
|
||||
await pending;
|
||||
expect(getServer('managed')).toMatchObject({ last_error: 'timeout:manual failure', last_seen_at: null, last_checked_at: new Date(at + 10_000).toISOString() });
|
||||
@@ -189,7 +205,7 @@ describe('database fleet and container readiness', () => {
|
||||
: { ok: true as const, state: 'running' };
|
||||
} finally { active--; }
|
||||
});
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } });
|
||||
const events: unknown[] = [];
|
||||
const unsubscribe = subscribe(event => events.push(event));
|
||||
@@ -244,7 +260,7 @@ describe('database fleet and container readiness', () => {
|
||||
try {
|
||||
await (host === 'host-0' ? slow : fast);
|
||||
if (name === 'host-0-0') throw new Error('simulated failed probe');
|
||||
return { ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
return { ok: true, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } };
|
||||
} finally { active.set(host, active.get(host)! - 1); }
|
||||
});
|
||||
@@ -283,7 +299,7 @@ describe('database fleet and container readiness', () => {
|
||||
inspect.mockImplementation(async name => name === 'slow'
|
||||
? await new Promise<{ ok: true; state: string }>(resolve => { release = resolve; })
|
||||
: { ok: true as const, state: 'running' });
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } });
|
||||
const pending = fanoutStatus();
|
||||
try {
|
||||
@@ -305,7 +321,7 @@ describe('database fleet and container readiness', () => {
|
||||
rcon.mockImplementation(() => new Promise(resolve => { release = resolve; }));
|
||||
const pending = POST({ params: { name: 'managed' } } as never);
|
||||
db.prepare("INSERT INTO restart_rollout (server, requested_at) VALUES ('managed', ?)").run(new Date().toISOString());
|
||||
release({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'old status',
|
||||
release({ ok: true, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } });
|
||||
expect(await (await pending).json()).toMatchObject({ ok: true });
|
||||
expect(getServer('managed')).toMatchObject({ last_seen_at: null, last_error: 'timeout:old process' });
|
||||
@@ -321,7 +337,7 @@ describe('database fleet and container readiness', () => {
|
||||
});
|
||||
rcon.mockImplementation(async () => {
|
||||
if (phase === 'rcon') await blocked;
|
||||
return { ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'old status',
|
||||
return { ok: true, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } };
|
||||
});
|
||||
const pending = fanoutStatus();
|
||||
@@ -346,7 +362,7 @@ describe('database fleet and container readiness', () => {
|
||||
expect(inspect).not.toHaveBeenCalled();
|
||||
expect(rcon).not.toHaveBeenCalled();
|
||||
expect(events).toContainEqual(expect.objectContaining({ type: 'server-status', entries: [expect.objectContaining({
|
||||
health: 'provisioning', lastError: null, reason: null, replySnippet: null
|
||||
health: 'provisioning', lastError: null, reason: null
|
||||
})] }));
|
||||
db.prepare("UPDATE server_deployments SET state = 'failed' WHERE id = 'creation'").run();
|
||||
inspect.mockResolvedValue({ ok: true, state: 'running' });
|
||||
@@ -354,7 +370,7 @@ describe('database fleet and container readiness', () => {
|
||||
await fanoutStatus();
|
||||
expect(await snapshot()).toMatchObject({ health: 'offline', last_error: 'timeout:no reply' });
|
||||
db.prepare("UPDATE server_deployments SET state = 'done' WHERE id = 'creation'").run();
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } });
|
||||
await fanoutStatus();
|
||||
expect(await snapshot()).toMatchObject({ health: 'online', last_error: null });
|
||||
@@ -363,7 +379,7 @@ describe('database fleet and container readiness', () => {
|
||||
it('keeps game_end through occupied polls until game_start or an empty lobby', async () => {
|
||||
const { inspect, rcon, fanoutStatus, getServer } = await setup();
|
||||
inspect.mockResolvedValue({ ok: true, state: 'running' });
|
||||
const reply = (players: number) => ({ ok: true as const, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
const reply = (players: number) => ({ ok: true as const, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players, map: 'zombie_theater', gametype: 'zom', roster: [] } });
|
||||
rcon.mockResolvedValue(reply(1));
|
||||
await postTelemetry(gameEvent('game_start', 0, 1, 'managed'));
|
||||
@@ -396,7 +412,7 @@ describe('database fleet and container readiness', () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(startedAt);
|
||||
inspect.mockResolvedValue({ ok: true, state: 'running' });
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players: 1, map: 'zombie_theater', gametype: 'zom', roster: [] } });
|
||||
expect((await postTelemetry(gameEvent('game_start', 0, 1, 'managed'))).status).toBe(200);
|
||||
expect(getServer('managed')).toMatchObject({ active_since: at(0), inactive_since: null, game_started_at: at(0) });
|
||||
@@ -430,7 +446,7 @@ describe('database fleet and container readiness', () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(startedAt - 20_000);
|
||||
inspect.mockResolvedValue({ ok: true, state: 'running' });
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players: 1, map: 'zombie_theater', gametype: 'zom', roster: [] } });
|
||||
await fanoutStatus();
|
||||
expect(getServer('managed')).toMatchObject({ active_since: at(-20), game_started_at: null });
|
||||
@@ -485,7 +501,7 @@ describe('database fleet and container readiness', () => {
|
||||
await mkdir(logs, { recursive: true });
|
||||
await writeFile(join(logs, 'games_sp.log'), '0:00 InitGame:\n0:01 J;123;0;Player\n');
|
||||
inspect.mockResolvedValue({ ok: true, state: 'running' });
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players: 1, map: 'zombie_theater', gametype: 'zom', roster: [] } });
|
||||
await fanoutStatus();
|
||||
expect(getServer('managed')).toMatchObject({ active_since: expect.any(String), game_started_at: null });
|
||||
@@ -524,7 +540,7 @@ describe('database fleet and container readiness', () => {
|
||||
const telemetry = gameEvent('heartbeat', 15, 3, 'managed', map);
|
||||
telemetry.players[0].ping = 42;
|
||||
telemetry.players[1].guid = '7097376';
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players: 4, map, gametype: 'zom', roster: [
|
||||
{ num: 0, guid: telemetry.players[0].guid, name: 'Old name', score: 50, ping: 100 },
|
||||
{ num: 1, guid: game === 't6' ? '6C4C20' : telemetry.players[1].guid, name: 'Another old name', score: 100, ping: 30 },
|
||||
@@ -574,7 +590,7 @@ describe('database fleet and container readiness', () => {
|
||||
inspect.mockResolvedValue({ ok: true, state: 'running' });
|
||||
await postTelemetry(gameEvent('heartbeat', 15, 3, 'managed'));
|
||||
db.prepare('UPDATE server_telemetry SET received_at = ?').run(new Date(Date.now() - 91_000).toISOString());
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } });
|
||||
await fanoutStatus();
|
||||
expect(rcon).toHaveBeenCalledTimes(1);
|
||||
@@ -605,7 +621,7 @@ describe('database fleet and container readiness', () => {
|
||||
expect(rcon).toHaveBeenCalledTimes(1);
|
||||
expect(getServer('managed')).toMatchObject({ container_state: 'running', last_error: 'timeout:no reply' });
|
||||
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } });
|
||||
await fanoutStatus();
|
||||
expect((await (await GET()).json()).servers[0]).toMatchObject({ health: 'online', last_error: null });
|
||||
@@ -629,7 +645,7 @@ describe('database fleet and container readiness', () => {
|
||||
if (state === 'throw') inspect.mockRejectedValue(new Error('Inspection timed out'));
|
||||
else inspect.mockResolvedValue({ ok: false, state, reason: 'Inspection timed out' });
|
||||
await postTelemetry(gameEvent('heartbeat', 15, 3, 'managed'));
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: 'status',
|
||||
rcon.mockResolvedValue({ ok: true, endpoint: { host: 'localhost', port: 28960 },
|
||||
status: { players: 2, map: 'zombie_theater', gametype: 'zom', roster: [] } });
|
||||
const events: unknown[] = [];
|
||||
const unsubscribe = subscribe(event => events.push(event));
|
||||
|
||||
@@ -38,7 +38,7 @@ beforeEach(async () => {
|
||||
const { rconGetPlayerCount, rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js');
|
||||
vi.mocked(rconGetPlayerCount).mockResolvedValue(0);
|
||||
vi.mocked(rconStatusResult).mockResolvedValue({
|
||||
ok: true, endpoint: { host: 'test', port: 28960 }, rawReply: '',
|
||||
ok: true, endpoint: { host: 'test', port: 28960 },
|
||||
status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] }
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ function status(timestamp: number, changes: Partial<ServerStatus> = {}): ServerS
|
||||
return {
|
||||
server: 't5_zombie_theater_1', players: 0, map: 'zombie_theater', gametype: 'zom',
|
||||
roster: [], health: 'online', lastSeenAt: new Date(timestamp).toISOString(),
|
||||
lastError: null, endpoint: null, reason: null, replySnippet: null,
|
||||
lastError: null, endpoint: null, reason: null,
|
||||
activeSince: null, inactiveSince: new Date(epoch).toISOString(),
|
||||
gameStartedAt: null, gameEndedAt: null, ...changes
|
||||
};
|
||||
|
||||
@@ -55,12 +55,12 @@ it('omits the fixed instance selector while keeping promoted/live distinction',
|
||||
expect(body).toContain('Live files and active betas may differ');
|
||||
});
|
||||
|
||||
function pageBody(tab: string) {
|
||||
(page as unknown as ReturnType<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`) });
|
||||
function pageBody(tab: string, role = 'admin') {
|
||||
(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 server = { name: 't5_zombie_theater_1', game: 't5', map: 'zombie_theater', last_map: 'zombie_theater', health: 'online', last_players: 1,
|
||||
last_seen_at: telemetry.receivedAt, last_error: null, held: [], telemetry, status_source: 'telemetry', host_id: 'host-1', native_instance_id: 'instance-1',
|
||||
host: { name: 'ec-1', connected: true, lastHeartbeat: telemetry.receivedAt },
|
||||
host: { name: 'ec-1', connected: true, lastHeartbeat: telemetry.receivedAt }, replySnippet: 'legacy private reply 192.0.2.123:28960',
|
||||
rcon_status: { last_seen_at: null, last_error: 'timeout: no reply' }, game_started_at: null, game_ended_at: null, active_since: null, inactive_since: null };
|
||||
const data = { serverSnapshot: { servers: [server], operations: [], serverNow: get(uiNow), integrations: {}, reservedNames: [] } } as unknown as ComponentProps<typeof ServerPage>['data'];
|
||||
return render(ServerPage, { props: { data } }).body;
|
||||
@@ -83,6 +83,15 @@ it('reports RCON failures independently of fresh telemetry and defers the reques
|
||||
expect(body).toContain('Connection details');
|
||||
expect(body).not.toContain('Telemetry API requests');
|
||||
expect(body).not.toContain('>Healthy<');
|
||||
expect(body).toContain('Test connection');
|
||||
expect(body).not.toContain('legacy private reply');
|
||||
});
|
||||
|
||||
it('keeps connection summaries visible to viewers without raw diagnostics or probe controls', () => {
|
||||
const body = pageBody('diagnostics', 'viewer');
|
||||
expect(body).toContain('Game connection · RCON');
|
||||
expect(body).not.toContain('Test connection');
|
||||
expect(body).not.toContain('legacy private reply');
|
||||
});
|
||||
|
||||
it('uses the shared Activity view for server history', () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ rconGetPlayerCount
|
||||
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) }));
|
||||
|
||||
const server = 't5_zombie_theater_1';
|
||||
const healthyRcon = { ok: true as const, endpoint: { host: 'test', port: 28960 }, rawReply: '',
|
||||
const healthyRcon = { ok: true as const, endpoint: { host: 'test', port: 28960 },
|
||||
status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } };
|
||||
const missingRcon = { ok: false as const, endpoint: { host: 'test', port: 28960 }, reason: 'timeout' as const, detail: 'No reply' };
|
||||
let fixture: Fixture;
|
||||
|
||||
Reference in New Issue
Block a user