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:
@@ -0,0 +1,132 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
let fixture: Fixture;
|
||||
const inventory = { hostname: 'games-1', agentVersion: '1.0.0', protocolVersion: 1, os: 'Debian 13', architecture: 'amd64', dockerRunning: true, directoriesWritable: true, storageAvailable: 100 * 1024 ** 3, runtimeImages: ['plutainer:test'], libraries: [{ id: 'test-library', state: 'ready' }] };
|
||||
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
|
||||
afterEach(async () => { vi.useRealTimers(); vi.unstubAllEnvs(); await fixture.cleanup(); });
|
||||
|
||||
async function enrolled() {
|
||||
const hosts = await import('../../src/lib/server/hosts/index.js');
|
||||
const host = hosts.createHost({ name: 'Game host' });
|
||||
const enrollment = hosts.createEnrollment(host.id);
|
||||
return { ...hosts, host, enrollment, credentials: hosts.enrollHost(enrollment.token, inventory) };
|
||||
}
|
||||
|
||||
describe('native host enrollment and scoped authentication', () => {
|
||||
it('consumes an enrollment exactly once and stores only credential hashes', async () => {
|
||||
const api = await enrolled();
|
||||
expect(api.authenticateCredential(api.credentials.credential)?.id).toBe(api.host.id);
|
||||
expect(api.authenticateCredential(api.enrollment.token)).toBeNull();
|
||||
expect(() => api.enrollHost(api.enrollment.token, inventory)).toThrow('invalid or expired');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const stored = JSON.stringify(db().prepare('SELECT * FROM ec_host_credentials').all()) + JSON.stringify(db().prepare('SELECT * FROM ec_host_enrollments').all());
|
||||
expect(stored).not.toContain(api.credentials.credential); expect(stored).not.toContain(api.enrollment.token);
|
||||
});
|
||||
it('expires enrollments and invalidates earlier unused enrollment tokens', async () => {
|
||||
const api = await enrolled();
|
||||
const first = api.createEnrollment(api.host.id); const next = api.createEnrollment(api.host.id, 10);
|
||||
expect(() => api.enrollHost(first.token, inventory)).toThrow('invalid or expired');
|
||||
vi.useFakeTimers(); vi.setSystemTime(Date.now() + 20);
|
||||
expect(() => api.enrollHost(next.token, inventory)).toThrow('invalid or expired');
|
||||
});
|
||||
it('rotates credentials independently and immediately revokes access', async () => {
|
||||
const api = await enrolled(); const other = api.createHost({ name: 'Other' }); const second = api.enrollHost(api.createEnrollment(other.id).token, inventory);
|
||||
const rotated = api.rotateCredential(api.host.id);
|
||||
expect(api.authenticateCredential(api.credentials.credential)).toBeNull(); expect(api.authenticateCredential(rotated)?.id).toBe(api.host.id);
|
||||
api.revokeCredentials(api.host.id);
|
||||
expect(api.authenticateCredential(rotated)).toBeNull(); expect(api.authenticateCredential(second.credential)?.id).toBe(other.id);
|
||||
});
|
||||
it('requires HTTPS and blocks cross-origin management and agent credentials on management APIs', async () => {
|
||||
const api = await enrolled(); const { authenticateAgent, requireManagementOrigin } = await import('../../src/lib/server/hosts/http.js');
|
||||
const headers = { authorization: `Bearer ${api.credentials.credential}` };
|
||||
expect(authenticateAgent(new Request('https://ec.test/api/agent/operations', { headers })).id).toBe(api.host.id);
|
||||
expect(() => authenticateAgent(new Request('http://ec.test/api/agent/operations', { headers }))).toThrow();
|
||||
expect(() => authenticateAgent(new Request('https://ec.test/api/agent/operations', { headers: { authorization: `Bearer ${api.enrollment.token}` } }))).toThrow();
|
||||
expect(() => requireManagementOrigin(new Request('https://ec.test/api/hosts', { headers }))).toThrow();
|
||||
expect(() => requireManagementOrigin(new Request('https://ec.test/api/hosts', { headers: { Origin: 'https://attacker.test' } }))).toThrow();
|
||||
});
|
||||
it('recovers credential rotation when acknowledgement is lost using the agent-persisted replacement', async () => {
|
||||
const api = await enrolled(); const next = 'ec_host_' + randomBytes(32).toString('base64url');
|
||||
expect(api.rotateCredential(api.host.id, next)).toBe(next);
|
||||
expect(api.authenticateCredential(api.credentials.credential)).toBeNull();
|
||||
expect(api.authenticateCredential(next)?.id).toBe(api.host.id);
|
||||
expect(api.rotateCredential(api.host.id, next)).toBe(next);
|
||||
api.revokeCredentials(api.host.id); expect(() => api.rotateCredential(api.host.id, next)).toThrow('cannot be reused');
|
||||
});
|
||||
it('bounds chunked request bodies before consuming the complete upload', async () => {
|
||||
const { objectBody } = await import('../../src/lib/server/hosts/http.js');
|
||||
let cancelled = false;
|
||||
const body = new ReadableStream<Uint8Array>({ pull(controller) { controller.enqueue(new TextEncoder().encode('x'.repeat(100))); }, cancel() { cancelled = true; } });
|
||||
const request = new Request('https://ec.test/api/agent/operations/x', { method: 'POST', body, duplex: 'half' } as RequestInit);
|
||||
await expect(objectBody(request, 50)).rejects.toMatchObject({ status: 413 });
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('native host readiness and reservations', () => {
|
||||
it('separates a connected agent from ready Docker, storage, runtime and library state', async () => {
|
||||
const api = await enrolled();
|
||||
expect(api.requireReadyHost(api.host.id).readiness).toBe('ready');
|
||||
expect(() => api.requireReadyHost(api.host.id, { image: 'missing:image' })).toThrow('runtime');
|
||||
expect(() => api.requireReadyHost(api.host.id, { libraryId: 'missing' })).toThrow('library');
|
||||
expect(api.heartbeat(api.host.id, { ...inventory, libraries: [] }).readiness).toBe('preparing');
|
||||
const host = api.heartbeat(api.host.id, { ...inventory, dockerRunning: false });
|
||||
expect(host.connected).toBe(true); expect(host.readiness).toBe('degraded');
|
||||
expect(() => api.requireReadyHost(host.id)).toThrow('Docker');
|
||||
api.heartbeat(host.id, inventory);
|
||||
vi.useFakeTimers(); vi.setSystemTime(Date.now() + 91_000);
|
||||
expect(api.getHost(host.id)?.connectionState).toBe('offline'); expect(() => api.requireReadyHost(host.id)).toThrow('stale');
|
||||
});
|
||||
it('blocks new deployments on a draining or disabled host without altering existing operations', async () => {
|
||||
const api = await enrolled(); const existing = api.queueHostOperation(api.host.id, 'instance.inspect', {}, { instanceId: 'immutable-id', generation: 1 });
|
||||
api.updateHost(api.host.id, { mode: 'draining' });
|
||||
expect(() => api.queueHostOperation(api.host.id, 'instance.create', {})).toThrow('draining');
|
||||
expect(api.getHostOperation(existing.id)?.state).toBe('pending');
|
||||
api.updateHost(api.host.id, { mode: 'disabled' }); expect(api.pendingHostOperations(api.host.id)).toEqual([]);
|
||||
});
|
||||
it('allocates per host, bind address and protocol while protecting wildcard bindings', async () => {
|
||||
const api = await enrolled(); const second = api.createHost({ name: 'Other' });
|
||||
expect(api.reserveHostPort(api.host.id, 'a', { port: 28960 })).toBe(28960);
|
||||
expect(api.reserveHostPort(second.id, 'b', { port: 28960 })).toBe(28960);
|
||||
expect(api.reserveHostPort(api.host.id, 'c', { port: 28960, protocol: 'tcp' })).toBe(28960);
|
||||
expect(() => api.reserveHostPort(api.host.id, 'd', { port: 28960, bindAddress: '127.0.0.1' })).toThrow('available');
|
||||
expect(api.reserveHostPort(api.host.id, 'a', { port: 28960 })).toBe(28960);
|
||||
api.releaseHostPorts('a');
|
||||
expect(api.reserveHostPort(api.host.id, 'e', { port: 28960, bindAddress: '127.0.0.1' })).toBe(28960);
|
||||
expect(api.reserveHostPort(api.host.id, 'f', { port: 28960, bindAddress: '127.0.0.2' })).toBe(28960);
|
||||
expect(() => api.reserveHostPort(api.host.id, 'g', { port: 28960, bindAddress: '0.0.0.0' })).toThrow('available');
|
||||
expect(() => api.reserveHostPort(api.host.id, 'h', { port: 28000 })).toThrow('available');
|
||||
});
|
||||
});
|
||||
|
||||
describe('durable host operations', () => {
|
||||
it('rotates unresolved operation delivery so an old batch cannot starve new work', async () => {
|
||||
const api = await enrolled();
|
||||
const operations = Array.from({ length: 30 }, () => api.queueHostOperation(api.host.id, 'instance.inspect', {}));
|
||||
operations.forEach(operation => api.reportHostOperation(api.host.id, operation.id, { state: 'unknown' }));
|
||||
const first = api.pendingHostOperations(api.host.id).map(operation => operation.id);
|
||||
const second = api.pendingHostOperations(api.host.id).map(operation => operation.id);
|
||||
expect(new Set([...first, ...second]).size).toBe(30);
|
||||
expect(operations.every(operation => api.getHostOperation(operation.id)?.state === 'unknown')).toBe(true);
|
||||
});
|
||||
it('reconciles the same operation ID after loss of acknowledgement and prevents duplicate side effects', async () => {
|
||||
const api = await enrolled(); const options = { instanceId: 'immutable-id', generation: 4, idempotencyKey: 'provisioning:123' };
|
||||
const operation = api.queueHostOperation(api.host.id, 'instance.restart', {}, options);
|
||||
api.reportHostOperation(api.host.id, operation.id, { state: 'accepted' }); api.reportHostOperation(api.host.id, operation.id, { state: 'running' });
|
||||
expect(api.queueHostOperation(api.host.id, 'instance.restart', {}, options).id).toBe(operation.id);
|
||||
expect(api.pendingHostOperations(api.host.id).map(value => value.id)).toEqual([operation.id]);
|
||||
expect(() => api.queueHostOperation(api.host.id, 'instance.stop', {}, options)).toThrow('different request');
|
||||
api.reportHostOperation(api.host.id, operation.id, { state: 'completed', result: { restarted: true } });
|
||||
expect(api.reportHostOperation(api.host.id, operation.id, { state: 'running' }).state).toBe('completed');
|
||||
expect(api.pendingHostOperations(api.host.id)).toEqual([]);
|
||||
expect((await api.waitHostOperation(operation.id)).result).toEqual({ restarted: true });
|
||||
});
|
||||
it('does not allow another host to report a result, and timeouts retain the original operation', async () => {
|
||||
const api = await enrolled(); const other = api.createHost({ name: 'Other' }); const operation = api.queueHostOperation(api.host.id, 'instance.stop', {});
|
||||
expect(() => api.reportHostOperation(other.id, operation.id, { state: 'completed' })).toThrow('this host');
|
||||
await expect(api.waitHostOperation(operation.id, 0)).rejects.toThrow(operation.id);
|
||||
expect(api.getHostOperation(operation.id)?.state).toBe('pending');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user