Protect RCON diagnostics and redact player addresses (Refs #61)

This commit is contained in:
2026-10-08 16:47:34 -04:00
parent 29d2d9634c
commit 0cc4498b53
36 changed files with 280 additions and 83 deletions
+35 -19
View File
@@ -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));