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
+268
View File
@@ -0,0 +1,268 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Handle, RequestEvent } from '@sveltejs/kit';
import type { AuthUser, UserRole } from '../../src/lib/auth.js';
type Session = { user: AuthUser; tokenHash: string; expiresAt: number };
const auth = vi.hoisted(() => ({
session: null as Session | null, current: null as Session | null, setup: false,
readSession: vi.fn(), readSessionHash: vi.fn(), recordAudit: vi.fn(), setupRequired: vi.fn()
}));
vi.mock('../../src/lib/server/auth/index.js', () => ({
SESSION_COOKIE: 'ec_session',
AuthError: class AuthError extends Error { constructor(message: string, public status = 400) { super(message); } },
readSession: auth.readSession, readSessionHash: auth.readSessionHash, recordAudit: auth.recordAudit, setupRequired: auth.setupRequired
}));
import { authenticationHandle, protectEventStream } from '../../src/lib/server/auth/guard.js';
const token = 'x'.repeat(43);
function signIn(role: UserRole = 'viewer', mustChangePassword = false): Session {
const user: AuthUser = { id: 'user-1', username: 'tester', displayName: 'Test User', role, disabled: false,
mustChangePassword, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', lastLoginAt: null };
auth.session = { user, tokenHash: 'hash-of-session', expiresAt: Date.now() + 3600_000 };
auth.current = auth.session;
return auth.session;
}
type Resolve = Parameters<Handle>[0]['resolve'];
function requestFor(routeId: string | null, options: {
method?: string; path?: string; base?: string; origin?: string | null; headers?: HeadersInit;
body?: unknown; rawBody?: BodyInit; signal?: AbortSignal; resolve?: Resolve;
} = {}) {
const url = new URL(options.path ?? routeId ?? '/not-found', options.base ?? 'https://ec.test');
const method = options.method ?? 'GET'; const headers = new Headers(options.headers);
if (!['GET', 'HEAD'].includes(method) && options.origin !== null) headers.set('Origin', options.origin ?? url.origin);
if (options.body !== undefined && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
const request = new Request(url, { method, headers, body: options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body)), signal: options.signal });
const cookies = { get: vi.fn(() => token) };
const event = { request, url, route: { id: routeId }, locals: { user: null, sessionHash: null }, cookies } as unknown as RequestEvent;
const resolve = vi.fn(options.resolve ?? (async () => new Response('resolved', { headers: { 'Content-Type': 'text/plain' } })));
return { event, resolve, cookies, invoke: () => authenticationHandle({ event, resolve } as Parameters<Handle>[0]) };
}
beforeEach(() => {
vi.clearAllMocks(); auth.session = null; auth.current = null; auth.setup = false;
auth.readSession.mockImplementation(() => auth.session);
auth.readSessionHash.mockImplementation(() => auth.current);
auth.setupRequired.mockImplementation(() => auth.setup);
vi.stubEnv('EC_AUTH_ALLOW_HTTP', 'false');
});
afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); });
describe('browser authentication guard', () => {
it('redirects anonymous pages to login while APIs return JSON 401 without resolving', async () => {
const page = requestFor('/metrics', { path: '/metrics?days=7' });
await expect(page.invoke()).rejects.toMatchObject({ status: 303, location: '/login?next=%2Fmetrics%3Fdays%3D7' });
expect(page.resolve).not.toHaveBeenCalled();
const api = requestFor('/api/metrics'); const response = await api.invoke();
expect(response.status).toBe(401); expect(await response.json()).toMatchObject({ setupRequired: false });
expect(response.headers.get('cache-control')).toBe('no-store'); expect(api.resolve).not.toHaveBeenCalled();
});
it('directs first-run browsers to setup and tells APIs setup is required', async () => {
auth.setup = true;
await expect(requestFor('/').invoke()).rejects.toMatchObject({ status: 303, location: '/setup' });
const response = await requestFor('/api/fleet').invoke();
expect(response.status).toBe(401); expect(await response.json()).toMatchObject({ setupRequired: true });
expect((await requestFor('/setup').invoke()).status).toBe(200);
});
it('handles login/setup redirects without exposing management pages', async () => {
await expect(requestFor('/setup').invoke()).rejects.toMatchObject({ location: '/login' });
auth.setup = true;
await expect(requestFor('/login').invoke()).rejects.toMatchObject({ location: '/setup' });
auth.setup = false; signIn('viewer');
await expect(requestFor('/login').invoke()).rejects.toMatchObject({ location: '/' });
signIn('operator', true);
await expect(requestFor('/login').invoke()).rejects.toMatchObject({ location: '/account' });
});
it.each([
['viewer', '/api/fleet', 'GET', 200], ['viewer', '/api/servers', 'POST', 403],
['viewer', '/api/configs/[id]', 'GET', 403], ['operator', '/api/configs/[id]', '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) => {
const session = signIn(role); const call = requestFor(routeId, { method, body: method === 'GET' ? undefined : {} });
const response = await call.invoke(); expect(response.status).toBe(expectedStatus);
if (expectedStatus === 200) { expect(call.resolve).toHaveBeenCalledOnce(); expect(call.event.locals.user).toEqual(session.user); }
else expect(call.resolve).not.toHaveBeenCalled();
});
it('requires temporary-password users to change it even if their role is administrator', async () => {
signIn('admin', true);
await expect(requestFor('/settings').invoke()).rejects.toMatchObject({ status: 303, location: '/account' });
const blocked = await requestFor('/api/users').invoke();
expect(blocked.status).toBe(403); expect(await blocked.json()).toMatchObject({ mustChangePassword: true });
for (const [route, method] of [['/account', 'GET'], ['/api/auth/me', 'GET'], ['/api/auth/password', 'POST'], ['/api/auth/logout', 'POST']]) {
expect((await requestFor(route, { method, body: method === 'POST' ? {} : undefined }).invoke()).status).toBe(200);
}
});
it('adds private response headers and audits successful writes without recording request secrets', async () => {
signIn('admin');
const call = requestFor('/api/servers', { method: 'POST', body: { plutoKey: 'secret-server-key' } });
const response = await call.invoke();
expect(response.headers.get('cache-control')).toBe('no-store');
expect(response.headers.get('vary')).toContain('Cookie');
expect(response.headers.get('x-frame-options')).toBe('DENY');
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
expect(auth.recordAudit).toHaveBeenCalledWith('user-1', 'request', '/api/servers', { method: 'POST', status: 200, path: '/api/servers' });
expect(JSON.stringify(auth.recordAudit.mock.calls)).not.toContain('secret-server-key');
});
it('does not audit denied or unsuccessful writes as successful actions', async () => {
signIn('viewer'); await requestFor('/api/servers', { method: 'POST', body: {} }).invoke();
expect(auth.recordAudit).not.toHaveBeenCalled();
signIn('admin'); await requestFor('/api/servers', { method: 'POST', body: {}, resolve: async () => new Response('bad', { status: 400 }) }).invoke();
expect(auth.recordAudit).not.toHaveBeenCalled();
});
});
describe('origin and transport enforcement', () => {
it.each(['/api/auth/login', '/api/auth/setup', '/api/servers'])('requires exact Origin for %s, including public auth requests', async route => {
signIn('admin');
for (const origin of [null, 'null', 'https://attacker.test', 'https://ec.test.attacker.test', 'http://ec.test', 'https://ec.test:8443']) {
const call = requestFor(route, { method: 'POST', origin, body: {} });
expect((await call.invoke()).status).toBe(403); expect(call.resolve).not.toHaveBeenCalled();
}
expect((await requestFor(route, { method: 'POST', body: {} }).invoke()).status).toBe(200);
});
it('rejects explicitly cross-site requests even if their Origin header matches', async () => {
signIn('admin');
const call = requestFor('/api/auth/login', { method: 'POST', body: {}, headers: { 'Sec-Fetch-Site': 'cross-site' } });
expect((await call.invoke()).status).toBe(403); expect(call.resolve).not.toHaveBeenCalled();
});
it('applies origin validation to multipart artifact uploads without consuming their body', async () => {
signIn('operator');
const form = new FormData(); form.set('name', 'script'); form.set('content', new Blob(['script bytes']), 'test.gsc');
const denied = requestFor('/api/scripts', { method: 'POST', origin: null, rawBody: form });
expect((await denied.invoke()).status).toBe(403);
const allowed = requestFor('/api/scripts', { method: 'POST', rawBody: form, resolve: async event => {
expect((await event.request.formData()).get('name')).toBe('script'); return new Response('uploaded', { status: 201 });
} });
expect((await allowed.invoke()).status).toBe(201);
});
it('requires HTTPS by default even on loopback', async () => {
signIn('admin');
for (const base of ['http://localhost', 'http://127.0.0.1', 'http://[::1]', 'http://private.lan']) {
const call = requestFor('/api/fleet', { base });
expect((await call.invoke()).status).toBe(426); expect(call.resolve).not.toHaveBeenCalled();
}
});
it('permits insecure developer sessions only with the explicit flag and a literal loopback hostname', async () => {
signIn('admin'); vi.stubEnv('EC_AUTH_ALLOW_HTTP', 'true');
for (const base of ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://[::1]:3000']) {
const call = requestFor('/api/fleet', { base }); expect((await call.invoke()).status).toBe(200);
expect(call.cookies.get).toHaveBeenCalledWith('ec_session_dev');
}
for (const base of ['http://private.lan', 'http://localhost.attacker.test', 'http://192.168.1.2']) {
expect((await requestFor('/api/fleet', { base }).invoke()).status).toBe(426);
}
const secure = requestFor('/api/fleet'); await secure.invoke(); expect(secure.cookies.get).toHaveBeenCalledWith('ec_session');
});
});
describe('machine and route identity boundaries', () => {
it.each([['/api/agent/operations', 'GET'], ['/api/agent/enroll', 'POST'], ['/api/agent/heartbeat', 'POST'], ['/api/telemetry', 'POST']] as const)('passes %s to its machine authentication without consulting browser sessions', async (routeId, method) => {
auth.readSession.mockImplementation(() => { throw new Error('Browser session must not be consulted'); });
auth.setupRequired.mockImplementation(() => { throw new Error('Browser setup must not be consulted'); });
const call = requestFor(routeId, { method, origin: null, base: 'http://host.lan', body: method === 'POST' ? {} : undefined,
resolve: async () => new Response('machine credential rejected', { status: 401 }) });
const response = await call.invoke(); expect(response.status).toBe(401); expect(call.resolve).toHaveBeenCalledOnce();
expect(auth.readSession).not.toHaveBeenCalled(); expect(auth.setupRequired).not.toHaveBeenCalled();
expect(call.event.locals.user).toBeNull();
});
it('does not allow host/enrollment bearer credentials to substitute for operator access even with a browser cookie', async () => {
signIn('admin');
for (const authorization of ['Bearer ec_host_secret', 'Bearer ec_enroll_secret']) {
const call = requestFor('/api/fleet', { headers: { Authorization: authorization } });
expect((await call.invoke()).status).toBe(403); expect(call.resolve).not.toHaveBeenCalled();
}
const machine = requestFor('/api/agent/operations', { headers: { Authorization: 'Bearer ec_host_secret' } });
expect((await machine.invoke()).status).toBe(200); expect(machine.resolve).toHaveBeenCalledOnce();
});
it('protects encoded management paths and Svelte page data using the resolved route ID', async () => {
signIn('viewer');
for (const path of ['/api/%75sers', '/api/%61gent/../users', '/_app/pretend-public.js']) {
const call = requestFor('/api/users', { path });
expect((await call.invoke()).status).toBe(403); expect(call.resolve).not.toHaveBeenCalled();
}
const data = requestFor('/settings/users', { path: '/settings/users/__data.json' });
await expect(data.invoke()).rejects.toMatchObject({ status: 403 }); expect(data.resolve).not.toHaveBeenCalled();
const rawConfig = requestFor('/api/configs/[id]', { path: '/api/configs/123?x=/_app/public.css' });
expect((await rawConfig.invoke()).status).toBe(403);
});
it('exempts only static assets on unresolved routes and does not use arbitrary file extensions as public access', async () => {
for (const path of ['/_app/immutable/start.js', '/favicon.svg', '/agent/ec-agent.py', '/agent/install.sh', '/agent/manifest.json']) {
const call = requestFor(null, { path }); expect((await call.invoke()).status).toBe(200); expect(call.resolve).toHaveBeenCalledOnce();
}
for (const path of ['/private.json', '/secrets.css', '/settings/users/__data.json']) {
const call = requestFor(null, { path }); await expect(call.invoke()).rejects.toMatchObject({ status: 303 }); expect(call.resolve).not.toHaveBeenCalled();
}
const write = requestFor(null, { path: '/_app/anything.js', method: 'POST', body: {} });
await expect(write.invoke()).rejects.toMatchObject({ status: 303 }); expect(write.resolve).not.toHaveBeenCalled();
});
});
describe('operator launch credential restrictions', () => {
it.each([
['/api/servers', { plutoKey: 'new-key' }],
['/api/deployment-collections', { keys: { t5_zombie_theater_1: 'new-key' } }]
])('rejects supplied administrator-managed keys at %s', async (route, body) => {
signIn('operator'); const call = requestFor(route as string, { method: 'POST', body });
expect((await call.invoke()).status).toBe(403); expect(call.resolve).not.toHaveBeenCalled();
});
it.each([
['/api/servers', { previewId: 'reviewed', plutoKey: '' }],
['/api/servers', { previewId: 'reviewed' }],
['/api/deployment-collections', { previewId: 'reviewed', keys: { t5_zombie_theater_1: '' } }],
['/api/deployment-collections', { previewId: 'reviewed', keys: {} }]
])('allows saved-key launches at %s and preserves the body for the handler', async (route, body) => {
signIn('operator');
const call = requestFor(route as string, { method: 'POST', body, resolve: async event => {
expect(await event.request.json()).toEqual(body); return new Response('queued', { status: 202 });
} });
expect((await call.invoke()).status).toBe(202); expect(call.resolve).toHaveBeenCalledOnce();
});
it('allows administrators to supply keys and rejects oversized operator launch bodies before handler execution', async () => {
signIn('admin'); expect((await requestFor('/api/servers', { method: 'POST', body: { plutoKey: 'new-key' } }).invoke()).status).toBe(200);
signIn('operator');
const tooLarge = requestFor('/api/servers', { method: 'POST', body: { plutoKey: 'x'.repeat(1024 * 1024 + 1) } });
expect((await tooLarge.invoke()).status).toBe(413); expect(tooLarge.resolve).not.toHaveBeenCalled();
});
});
describe('authenticated event streams', () => {
function streaming() {
let source!: ReadableStreamDefaultController<Uint8Array>;
const cancel = vi.fn();
const response = new Response(new ReadableStream<Uint8Array>({ start(controller) { source = controller; }, cancel }), { headers: { 'Content-Type': 'text/event-stream' } });
const abort = new AbortController();
return { response, source, cancel, abort };
}
it.each(['revoked', 'password-reset', 'role-demoted', 'storage-failure'] as const)('closes and cancels an established event stream when %s', async change => {
vi.useFakeTimers(); signIn('operator'); const source = streaming();
const protectedResponse = protectEventStream(source.response, 'hash-of-session', 'operator', source.abort.signal);
const reader = protectedResponse.body!.getReader();
source.source.enqueue(new TextEncoder().encode('event: test\n\n'));
expect((await reader.read()).done).toBe(false);
if (change === 'revoked') auth.current = null;
if (change === 'password-reset') auth.current = { ...auth.current!, user: { ...auth.current!.user, mustChangePassword: true } };
if (change === 'role-demoted') auth.current = { ...auth.current!, user: { ...auth.current!.user, role: 'viewer' } };
if (change === 'storage-failure') auth.readSessionHash.mockImplementation(() => { throw new Error('Storage unavailable'); });
const ended = reader.read(); await vi.advanceTimersByTimeAsync(15_000);
expect(await ended).toEqual({ done: true, value: undefined }); expect(source.cancel).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
it('rechecks valid streams without refreshing idle sessions and cancels upstream when the request aborts', async () => {
vi.useFakeTimers(); signIn('viewer'); const source = streaming();
const response = protectEventStream(source.response, 'hash-of-session', 'viewer', source.abort.signal);
const reader = response.body!.getReader(); const ended = reader.read();
await vi.advanceTimersByTimeAsync(15_000);
expect(auth.readSessionHash).toHaveBeenCalledWith('hash-of-session'); expect(source.cancel).not.toHaveBeenCalled();
source.abort.abort();
expect(await ended).toEqual({ done: true, value: undefined }); expect(source.cancel).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
it('wraps event-stream responses at the guard while preserving content type and denying anonymous subscriptions', async () => {
const anonymous = requestFor('/api/events/servers'); expect((await anonymous.invoke()).status).toBe(401); expect(anonymous.resolve).not.toHaveBeenCalled();
vi.useFakeTimers(); signIn('viewer'); const source = streaming();
const call = requestFor('/api/events/servers', { signal: source.abort.signal, resolve: async () => source.response });
const response = await call.invoke(); expect(response.headers.get('content-type')).toBe('text/event-stream');
const reader = response.body!.getReader(); const ended = reader.read();
auth.current = null; await vi.advanceTimersByTimeAsync(15_000);
expect(await ended).toEqual({ done: true, value: undefined }); expect(source.cancel).toHaveBeenCalledOnce();
});
});