Add native host management and role-based operator authentication

Introduce an outbound Python host agent, Debian installer, durable remote
operations, immutable game-file libraries, host-aware deployments and ports,
and occupancy-safe campaigns and draining. Native hosts do not require Komodo.

Add administrator, operator and viewer accounts, protected management routes,
secure sessions, password recovery, audit history and separate telemetry tokens.
Document first-admin setup, reporter migration and live-host acceptance checks.

Validation: 1,343 TypeScript tests and 39 agent tests passed, along with type
checking, the production build, compiled SSR/API smoke tests and administrator
recovery. Real Debian/Docker T5/T6 acceptance remains unrun.
This commit is contained in:
2026-10-03 11:42:36 -04:00
parent a2e2311aa9
commit e5c0fda1ed
127 changed files with 8485 additions and 657 deletions
+121
View File
@@ -0,0 +1,121 @@
import { randomUUID } from 'node:crypto';
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: [] } })),
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 () => {}) }));
let fixture: Fixture; let hostId: string; let instanceId: string;
const server = 't5_zombie_theater_1';
const healthy = { exists: true, healthy: true, state: 'running' };
beforeEach(async () => {
vi.resetModules(); vi.clearAllMocks(); fixture = await newFixture([]);
vi.stubEnv('KOMODO_ADDRESS', ''); vi.stubEnv('KOMODO_API_KEY', ''); vi.stubEnv('KOMODO_API_SECRET', '');
const { createHost, createEnrollment, enrollHost } = await import('../../src/lib/server/hosts/index.js');
hostId = createHost({ name: 'Safety test host' }).id;
enrollHost(createEnrollment(hostId).token, { protocolVersion: 1, dockerRunning: true, directoriesWritable: true,
storageAvailable: 100_000_000_000, runtimeImages: ['plutainer:test'], libraries: [{ id: 'published-game-version', state: 'ready' }] });
const { db } = await import('../../src/lib/server/db/index.js');
instanceId = randomUUID();
db().prepare("INSERT INTO servers(name,komodo_name,game,map,game_mode,map_slug,game_port,container_state) VALUES(?,'','t5','zombie_theater','zombie','theater',28960,'running')").run(server);
db().prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES(?,?,?,'published-game-version','plutainer:test',28960,?)`).run(instanceId, server, hostId, new Date().toISOString());
const backend = await import('../../src/lib/server/backends/native.js');
vi.spyOn(backend, 'nativeRequest').mockImplementation(async (_instance, kind) => {
if (kind !== 'instance.inspect') throw new Error(`Unexpected remote mutation: ${kind}`);
return healthy as never;
});
});
afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); vi.restoreAllMocks(); });
async function advance() {
const { processNativeProvisioning } = await import('../../src/lib/server/provisioning/native.js');
await processNativeProvisioning(() => null);
}
async function operationId(id: string) {
const { db } = await import('../../src/lib/server/db/index.js');
return (db().prepare('SELECT operation_id FROM native_provisioning WHERE deployment_id=?').get(id) as { operation_id: string | null }).operation_id;
}
async function waitingRestart() {
const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js');
const result = requestLifecycle(server, 'restart'); if (!result.ok) throw new Error(result.error);
return result.deploymentId;
}
async function suspendInspection() {
const { nativeRequest } = await import('../../src/lib/server/backends/native.js');
let release!: () => void;
vi.mocked(nativeRequest).mockImplementationOnce(async () => { await new Promise<void>(resolve => { release = resolve; }); return healthy as never; });
const running = advance();
await vi.waitFor(() => expect(release).toBeTypeOf('function'));
return { running, release };
}
async function stageCampaign() {
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare("INSERT INTO deployments(id,action,started_at) VALUES('campaign-race','promote',?)").run(new Date().toISOString());
db().prepare("INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('campaign-race',?,1,'pending')").run(server);
const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js');
queueFileChange({ sourceKind: 'campaign', sourceId: 'campaign-race', server, kind: 'script', relPath: 'hud.gsc', content: Buffer.from('reviewed campaign bytes'), inventorySource: 'campaign:test' });
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
const restart = requestRestart(server, { kind: 'campaign', id: 'campaign-race' });
await advance();
const staged = db().prepare('SELECT stage_operation_id FROM native_provisioning WHERE deployment_id=?').get(restart.id) as { stage_operation_id: string };
expect(staged.stage_operation_id).toBeTruthy();
const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
reportHostOperation(hostId, staged.stage_operation_id, { state: 'completed', result: { state: 'staged' } });
return restart.id;
}
describe('native disruption safety at dispatch', () => {
it('lets fresh occupied telemetry veto an empty RCON result and resumes after that evidence expires', async () => {
const id = await waitingRestart();
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
saveTelemetry({ event: 'game_start', server, map: 'zombie_theater', players: [{ name: 'Still playing', guid: 'player-1' }], receivedAt: new Date().toISOString() });
await advance();
expect(await operationId(id)).toBeNull();
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare("SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.restart'").get()).toEqual({ count: 0 });
db().prepare('UPDATE server_telemetry SET received_at=? WHERE server=?').run('2000-01-01T00:00:00Z', server);
await advance();
expect(await operationId(id)).toBeTypeOf('string');
});
it('holds destruction if a beta becomes active while remote inspection is pending', async () => {
const { destroyServer } = await import('../../src/lib/server/provisioning/instances.js');
const removal = destroyServer(server); if (!removal.ok || !removal.deploymentId) throw new Error('Removal was not queued.');
const pending = await suspendInspection();
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { script, version } = createScript({ name: 'Race beta', kind: 'script', targetRelPath: 'beta.gsc', content: Buffer.from('main() {}'), scopeType: 'game', scopeValue: 't5' });
const { db } = await import('../../src/lib/server/db/index.js');
const betaId = db().prepare("INSERT INTO betas(script_id,script_version_id,trigger_kind,state) VALUES(?,?,'manual','running')").run(script.id, version.id).lastInsertRowid;
db().prepare("INSERT INTO beta_servers(beta_id,server,had_original,stage) VALUES(?,?,0,'awaiting-empty')").run(betaId, server);
pending.release(); await pending.running;
expect(await operationId(removal.deploymentId)).toBeNull();
expect(db().prepare('SELECT name FROM servers WHERE name=?').get(server)).toEqual({ name: server });
expect(db().prepare("SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.remove'").get()).toEqual({ count: 0 });
});
it.each(['cancel', 'change bytes'] as const)('does not activate staged files after a %s during remote inspection', async change => {
const id = await stageCampaign();
const pending = await suspendInspection();
const { cancelStagedFiles, queueFileChange, stagedFileChanges } = await import('../../src/lib/server/fleet/staged-files.js');
if (change === 'cancel') cancelStagedFiles('campaign', 'campaign-race', server);
else queueFileChange({ sourceKind: 'campaign', sourceId: 'campaign-race', server, kind: 'script', relPath: 'hud.gsc', content: Buffer.from('different unreviewed bytes'), inventorySource: 'campaign:test' });
pending.release(); await pending.running;
expect(await operationId(id)).toBeNull();
expect(stagedFileChanges('campaign', 'campaign-race')[0].state).toBe(change === 'cancel' ? 'cancelled' : 'pending');
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare("SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.restart'").get()).toEqual({ count: 0 });
});
it('does not dispatch an old generation if the instance changes during inspection', async () => {
const id = await waitingRestart();
const pending = await suspendInspection();
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE native_instances SET generation=2 WHERE id=?').run(instanceId);
pending.release(); await pending.running;
expect(await operationId(id)).toBeNull();
expect(db().prepare("SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.restart'").get()).toEqual({ count: 0 });
});
});