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
+103
View File
@@ -0,0 +1,103 @@
import { createHash } from 'node:crypto';
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdir, lstat, rename, rm, stat } from 'node:fs/promises';
import { dirname, isAbsolute, resolve, sep } from 'node:path';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { randomUUID } from 'node:crypto';
import type { GameFileEntry } from '../../game-files.js';
export function validateGamePath(input: unknown): string {
if (typeof input !== 'string' || !input || input.length > 1024 || /[\\\x00-\x1f\x7f:]/.test(input) || Buffer.from(input).toString('utf8') !== input
|| isAbsolute(input) || input.split('/').some(part => !part || part === '.' || part === '..' || /[. ]$/.test(part))) {
throw new Error('Game file paths must be relative, normalized paths without traversal.');
}
return input;
}
export function validateManifest(input: unknown): GameFileEntry[] {
if (!Array.isArray(input) || !input.length || input.length > 200_000) throw new Error('Provide a manifest containing 1–200000 files.');
const paths = new Set<string>();
let total = 0;
const files = input.map(raw => {
if (!raw || typeof raw !== 'object') throw new Error('Invalid manifest entry.');
const path = validateGamePath(raw.path);
if (!Number.isSafeInteger(raw.size) || raw.size < 0 || typeof raw.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(raw.sha256)) throw new Error(`Invalid size or SHA256 for ${path}.`);
const key = path.toLowerCase();
if (paths.has(key)) throw new Error(`Duplicate game file path: ${path}.`);
paths.add(key); total += raw.size;
if (!Number.isSafeInteger(total)) throw new Error('Library size exceeds the supported limit.');
return { path, size: raw.size as number, sha256: raw.sha256 as string };
}).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
for (const file of files) {
const parts = file.path.toLowerCase().split('/');
parts.pop();
while (parts.length) {
if (paths.has(parts.join('/'))) throw new Error('A manifest path cannot be both a file and a directory.');
parts.pop();
}
}
return files;
}
export const manifestChecksum = (files: GameFileEntry[]): string => createHash('sha256').update(JSON.stringify(files)).digest('hex');
export function gameFilePath(root: string, path: string): string {
const base = resolve(root);
const result = resolve(base, validateGamePath(path));
if (!result.startsWith(base + sep)) throw new Error('Invalid game file path.');
return result;
}
/** Refuse symlinks at every existing component, including the managed root. */
export async function assertRegularPath(root: string, path: string, allowMissing = false): Promise<string> {
const result = gameFilePath(root, path);
let current = resolve(root);
const components = [current, ...validateGamePath(path).split('/').map(part => current = resolve(current, part))];
for (let i = 0; i < components.length; i++) {
let info;
try { info = await lstat(components[i]); }
catch (cause) { if (allowMissing && (cause as NodeJS.ErrnoException).code === 'ENOENT') continue; throw cause; }
if (info.isSymbolicLink() || (i < components.length - 1 ? !info.isDirectory() : !info.isFile())) throw new Error('Game storage contains a non-regular path.');
}
return result;
}
export async function verifyGameFile(root: string, file: GameFileEntry): Promise<void> {
const path = await assertRegularPath(root, file.path);
const info = await stat(path);
if (info.size !== file.size) throw new Error(`Size verification failed for ${file.path}.`);
const hash = createHash('sha256');
for await (const chunk of createReadStream(path)) hash.update(chunk);
if (hash.digest('hex') !== file.sha256) throw new Error(`Checksum verification failed for ${file.path}.`);
}
/** Stream to a private temporary file; publish only after byte count and SHA256 match. */
export async function receiveGameFile(root: string, file: GameFileEntry, body: ReadableStream<Uint8Array> | null): Promise<void> {
if (!body) throw new Error('A file body is required.');
const path = await assertRegularPath(root, file.path, true);
await mkdir(dirname(path), { recursive: true });
const temp = `${path}.upload-${randomUUID()}`;
let size = 0;
const hash = createHash('sha256');
const verifier = new Transform({ transform(chunk, _encoding, done) {
size += chunk.length;
if (size > file.size) return done(new Error('Upload exceeds the manifest size.'));
hash.update(chunk); done(null, chunk);
} });
try {
await pipeline(Readable.fromWeb(body as never), verifier, createWriteStream(temp, { flags: 'wx', mode: 0o600, flush: true }));
if (size !== file.size || hash.digest('hex') !== file.sha256) throw new Error(`Upload checksum or size mismatch for ${file.path}.`);
await rename(temp, path);
} finally { await rm(temp, { force: true }); }
}
export function parseByteRange(header: string | null, size: number): { start: number; end: number } | null {
if (!header) return null;
const match = /^bytes=(\d*)-(\d*)$/.exec(header);
if (!match || (!match[1] && !match[2]) || size === 0) throw new Error('Unsatisfiable range.');
let start = match[1] ? Number(match[1]) : Math.max(0, size - Number(match[2]));
let end = match[1] && match[2] ? Number(match[2]) : size - 1;
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start >= size || end < start || (!match[1] && Number(match[2]) === 0)) throw new Error('Unsatisfiable range.');
return { start, end: Math.min(end, size - 1) };
}