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
+1 -1
View File
@@ -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');
+3
View File
@@ -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) => {
+1 -1
View File
@@ -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(); });
+1 -1
View File
@@ -6,7 +6,7 @@ import type { CollectionMode } from '../../src/lib/deployment-plan.js';
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
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: [] } }))
}));
+1 -1
View File
@@ -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 () => {}) }));
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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 () => {}) }));
+3 -3
View File
@@ -6,7 +6,7 @@ import { newFixture, type Fixture } from '../helpers/fixture.js';
const remote = vi.hoisted(() => ({ state: 'running', players: 0, map: 'zombie_theater' }));
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 });
});
+1 -1
View File
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
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 () => {}) }));
+1 -1
View File
@@ -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');
+84
View File
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); });
async function setup() {
const database = await import('../../src/lib/server/db/index.js');
database.db().prepare(`INSERT INTO servers(name,game_port,last_error,last_reply_snippet)
VALUES ('server',28960,'bad-reply:unrecognized status','private reply 192.0.2.123:28960')`).run();
return database;
}
it('excludes both snippet fields from list/detail/page snapshots and initial and live SSE frames', async () => {
await setup();
const { GET: list } = await import('../../src/routes/api/servers/+server.js');
const { GET: detail } = await import('../../src/routes/api/servers/[name]/+server.js');
const { load } = await import('../../src/routes/servers/[name]/+page.server.js');
const { GET: stream } = await import('../../src/routes/api/events/servers/+server.js');
const { getServer } = await import('../../src/lib/server/fleet/servers.js');
const { statusEntry } = await import('../../src/lib/server/pipeline/worker.js');
const { emit } = await import('../../src/lib/server/pipeline/bus.js');
const safe = (text: string) => {
for (const value of ['replySnippet', 'last_reply_snippet', 'private reply', '192.0.2.123']) expect(text).not.toContain(value);
};
safe(await list().text());
safe(await detail({ params: { name: 'server' } }).text());
safe(JSON.stringify(load({ params: { name: 'server' } })));
const response = await stream();
const reader = response.body!.getReader();
const decoder = new TextDecoder();
try {
for (let index = 0; index < 3; index++) safe(decoder.decode((await reader.read()).value));
emit({ type: 'server-status', entries: [statusEntry(getServer('server')!)] });
const live = decoder.decode((await reader.read()).value);
expect(live).toContain('event: server-status');
safe(live);
} finally { await reader.cancel(); }
});
it('serves saved failure diagnostics without caching or probing and omits successful or missing evidence', async () => {
const { db } = await setup();
const rcon = await import('../../src/lib/server/integrations/rcon.js');
const probe = vi.spyOn(rcon, 'rconStatusResult');
const { GET } = await import('../../src/routes/api/servers/[name]/rcon-diagnostics/+server.js');
const response = GET({ params: { name: 'server' } });
expect(response.headers.get('Cache-Control')).toBe('no-store');
expect(await response.json()).toEqual({ replySnippet: 'private reply [redacted]' });
expect(probe).not.toHaveBeenCalled();
db().prepare("UPDATE servers SET last_error=NULL WHERE name='server'").run();
expect(await GET({ params: { name: 'server' } }).json()).toEqual({ replySnippet: null });
expect(GET({ params: { name: 'missing' } }).status).toBe(404);
db().prepare("UPDATE servers SET destroyed_at='2026-10-08' WHERE name='server'").run();
expect(GET({ params: { name: 'server' } }).status).toBe(404);
});
it('sanitizes manually probed failures before persistence and clears them on success', async () => {
const { db } = await setup();
const rcon = await import('../../src/lib/server/integrations/rcon.js');
const probe = vi.spyOn(rcon, 'rconStatusResult');
probe.mockResolvedValue({ ok: false, endpoint: null, reason: 'bad-reply', detail: 'unrecognized status',
rawReply: 'unrecognized player 192.0.2.123:28960' });
const { POST } = await import('../../src/routes/api/servers/[name]/probe-rcon/+server.js');
await POST({ params: { name: 'server' } } as never);
expect(db().prepare("SELECT last_reply_snippet FROM servers WHERE name='server'").get())
.toEqual({ last_reply_snippet: 'unrecognized player [redacted]' });
probe.mockResolvedValue({ ok: true, endpoint: { host: 'agent', port: 1 }, status: { players: 0, map: null, gametype: null, roster: [] } });
expect(await (await POST({ params: { name: 'server' } } as never)).json()).not.toHaveProperty('rawReply');
expect(db().prepare("SELECT last_reply_snippet FROM servers WHERE name='server'").get()).toEqual({ last_reply_snippet: null });
});
it('discards legacy plain text and hex snippets and unsafe error details once during upgrade', async () => {
const { db, closeDb } = await setup();
db().exec(`DELETE FROM settings WHERE key='rcon_private_diagnostics_v1';
UPDATE servers SET last_error='bad-reply:print reply not recognized: "player 192.0.2.123:28960"';
INSERT INTO servers(name,last_reply_snippet) VALUES ('legacy-hex','0000 31 39 32 2e 30 2e 32 2e 31 32 33 3a 32 38 39 36 192.0.2.123:2896');`);
closeDb();
expect(db().prepare('SELECT last_reply_snippet FROM servers').all()).toEqual([{ last_reply_snippet: null }, { last_reply_snippet: null }]);
expect(db().prepare("SELECT last_error FROM servers WHERE name='server'").get()).toEqual({ last_error: 'bad-reply:unparseable print reply' });
db().prepare("UPDATE servers SET last_reply_snippet='new sanitized failure' WHERE name='server'").run();
closeDb();
expect(db().prepare("SELECT last_reply_snippet FROM servers WHERE name='server'").get()).toEqual({ last_reply_snippet: 'new sanitized failure' });
});
+15
View File
@@ -48,6 +48,20 @@ afterEach(() => {
});
describe('RCON status transport', () => {
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
View File
@@ -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)', () => {
+1 -1
View File
@@ -7,7 +7,7 @@ import type { RebalanceMove } from '../../src/lib/rebalancing.js';
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}), kickProvisioning: vi.fn(async () => {}) }));
vi.mock('../../src/lib/server/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 }))
}));
+1 -1
View File
@@ -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' } });
}
+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));
+1 -1
View File
@@ -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: [] }
});
});
+1 -1
View File
@@ -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
};
+12 -3
View File
@@ -55,12 +55,12 @@ it('omits the fixed instance selector while keeping promoted/live distinction',
expect(body).toContain('Live files and active betas may differ');
});
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', () => {
+1 -1
View File
@@ -9,7 +9,7 @@ vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ rconGetPlayerCount
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) }));
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;