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,50 @@
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { relative, resolve, sep } from 'node:path';
|
||||
import { config } from '../config.js';
|
||||
import { nativeInstance, nativeRequest } from './native.js';
|
||||
|
||||
function remote(path: string) {
|
||||
for (const root of [config.serversRoot, config.t6ServersRoot]) {
|
||||
if (!root) continue;
|
||||
const rel = relative(resolve(root), resolve(path));
|
||||
if (!rel || rel.startsWith('..') || rel.startsWith(sep)) continue;
|
||||
const [name, ...parts] = rel.split(sep);
|
||||
const instance = nativeInstance(name);
|
||||
if (instance) return { instance, path: parts.join('/') || '.' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function missing(path: string): never { throw Object.assign(new Error(`File does not exist: ${path}`), { code: 'ENOENT' }); }
|
||||
|
||||
export async function readFile(path: string): Promise<Buffer> {
|
||||
const target = remote(path);
|
||||
if (!target) return fs.readFile(path);
|
||||
const result = await nativeRequest<{ contentBase64: string | null }>(target.instance, 'files.read', { path: target.path });
|
||||
return result.contentBase64 === null ? missing(path) : Buffer.from(result.contentBase64, 'base64');
|
||||
}
|
||||
|
||||
export async function exists(path: string): Promise<boolean> {
|
||||
try { await stat(path); return true; }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; throw error; }
|
||||
}
|
||||
|
||||
export async function stat(path: string): Promise<{ size: number; mtime: Date; isDirectory(): boolean; isFile(): boolean }> {
|
||||
const target = remote(path);
|
||||
if (!target) return fs.stat(path);
|
||||
const result = await nativeRequest<{ exists?: boolean; size: number; mtime: string | number; isDirectory: boolean; isFile: boolean } | null>(target.instance, 'files.stat', { path: target.path });
|
||||
if (result === null || result.exists === false) return missing(path);
|
||||
return { size: result.size, mtime: new Date(typeof result.mtime === 'number' ? result.mtime * 1000 : result.mtime), isDirectory: () => result.isDirectory, isFile: () => result.isFile };
|
||||
}
|
||||
|
||||
export async function readdir(path: string, options: { withFileTypes: true }): Promise<{ name: string; isDirectory(): boolean; isFile(): boolean }[]> {
|
||||
const target = remote(path);
|
||||
if (!target) return fs.readdir(path, options);
|
||||
const result = await nativeRequest<{ entries: { name: string; isDirectory: boolean; isFile?: boolean }[] }>(target.instance, 'files.list', { path: target.path });
|
||||
return result.entries.map(entry => ({ name: entry.name, isDirectory: () => entry.isDirectory, isFile: () => entry.isFile ?? !entry.isDirectory }));
|
||||
}
|
||||
|
||||
export async function nativeTail(path: string, lines: number) {
|
||||
const target = remote(path);
|
||||
if (!target) return undefined;
|
||||
return nativeRequest<import('../fleet/logs.js').TailResult | null>(target.instance, 'logs.read', { path: target.path, lines });
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { getHost, queueHostOperation, waitHostOperation } from '../hosts/index.js';
|
||||
|
||||
export interface NativeInstance {
|
||||
id: string; server: string; host_id: string; generation: number; library_id: string;
|
||||
image: string; bind_address: string; port: number; public_address: string | null;
|
||||
public_port: number | null; created_at: string; retired_at: string | null;
|
||||
}
|
||||
|
||||
export function nativeInstance(server: string): NativeInstance | null {
|
||||
return db().prepare('SELECT * FROM native_instances WHERE server = ? AND retired_at IS NULL').get(server) as NativeInstance | undefined ?? null;
|
||||
}
|
||||
|
||||
export function nativeDeployment(id: string): boolean {
|
||||
return !!db().prepare('SELECT 1 FROM native_provisioning WHERE deployment_id = ?').get(id);
|
||||
}
|
||||
|
||||
/** Only the agent receives host paths, Docker access, or game credentials. */
|
||||
export async function nativeRequest<T = Record<string, unknown>>(instance: NativeInstance, kind: string, payload: Record<string, unknown> = {}, key?: string): Promise<T> {
|
||||
const host = getHost(instance.host_id);
|
||||
if (!host || host.connectionState !== 'online') throw new Error('Host agent is offline; instance state is unknown.');
|
||||
const operation = queueHostOperation(instance.host_id, kind, payload, {
|
||||
instanceId: instance.id, generation: instance.generation, idempotencyKey: key ?? randomUUID()
|
||||
});
|
||||
const result = await waitHostOperation(operation.id, 25_000);
|
||||
if (result.state !== 'completed') throw new Error(result.error || `Agent operation ${result.id} is ${result.state}; its original ID is retained.`);
|
||||
// Do not apply replies from a retired generation to a replacement server.
|
||||
const current = nativeInstance(instance.server);
|
||||
if (current?.id !== instance.id || current.generation !== instance.generation) throw new Error('Instance was replaced during the operation.');
|
||||
return result.result as T;
|
||||
}
|
||||
|
||||
export async function nativeHealth(server: string): Promise<{ ok: true; state: string } | { ok: false; reason: string; state?: string }> {
|
||||
const instance = nativeInstance(server);
|
||||
if (!instance) return { ok: false, reason: 'Native instance is not registered' };
|
||||
try {
|
||||
const result = await nativeRequest<{ state: string; healthy: boolean }>(instance, 'instance.inspect');
|
||||
return result.healthy ? { ok: true, state: result.state } : { ok: false, state: result.state, reason: `Container ${result.state}` };
|
||||
} catch (error) { return { ok: false, state: 'unknown', reason: (error as Error).message }; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export function migrateNativeInstances(handle: Database.Database): void {
|
||||
handle.exec(`
|
||||
CREATE TABLE IF NOT EXISTS native_instances (
|
||||
id TEXT PRIMARY KEY, server TEXT NOT NULL, host_id TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL DEFAULT 1, library_id TEXT NOT NULL,
|
||||
image TEXT NOT NULL, bind_address TEXT NOT NULL DEFAULT '0.0.0.0',
|
||||
port INTEGER NOT NULL, public_address TEXT, public_port INTEGER,
|
||||
created_at TEXT NOT NULL, retired_at TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS native_instance_live_name ON native_instances(server) WHERE retired_at IS NULL;
|
||||
CREATE TABLE IF NOT EXISTS native_provisioning (
|
||||
deployment_id TEXT PRIMARY KEY REFERENCES server_deployments(id),
|
||||
instance_id TEXT NOT NULL REFERENCES native_instances(id), generation INTEGER NOT NULL,
|
||||
stage_operation_id TEXT, operation_id TEXT, files_json TEXT,
|
||||
applied_at TEXT, verified_at TEXT
|
||||
);
|
||||
`);
|
||||
}
|
||||
Reference in New Issue
Block a user