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:
Vendored
+6
-1
@@ -1,5 +1,10 @@
|
||||
declare global {
|
||||
namespace App {}
|
||||
namespace App {
|
||||
interface Locals {
|
||||
user: import('./lib/auth.js').AuthUser | null;
|
||||
sessionHash: string | null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
+3
-2
@@ -2,6 +2,7 @@ import type { Handle } from '@sveltejs/kit';
|
||||
import { migrate } from '$lib/server/db/migrate.js';
|
||||
import { startWorker } from '$lib/server/pipeline/worker.js';
|
||||
import { interruptDeployments } from '$lib/server/provisioning/history.js';
|
||||
import { authenticationHandle } from '$lib/server/auth/guard.js';
|
||||
|
||||
let booted = false;
|
||||
function boot(): void {
|
||||
@@ -15,7 +16,7 @@ function boot(): void {
|
||||
// SvelteKit calls handle() for every request; using the first request as the
|
||||
// boot trigger keeps the whole app single-process without needing an
|
||||
// out-of-band init hook.
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
export const handle: Handle = async (input) => {
|
||||
boot();
|
||||
return resolve(event);
|
||||
return authenticationHandle(input);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export type UserRole = 'admin' | 'operator' | 'viewer';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
role: UserRole;
|
||||
disabled: boolean;
|
||||
mustChangePassword: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastLoginAt: string | null;
|
||||
}
|
||||
|
||||
export const roleLabels: Record<UserRole, string> = {
|
||||
admin: 'Administrator', operator: 'Operator', viewer: 'Viewer'
|
||||
};
|
||||
@@ -11,6 +11,7 @@ export interface LaunchFileSummary {
|
||||
}
|
||||
|
||||
export interface LaunchRecipeSummary {
|
||||
native?: { hostId: string; libraryId: string; image?: string; bindAddress?: string; publicAddress?: string; publicPort?: number };
|
||||
game: ZombieGame;
|
||||
mapSlug: string;
|
||||
mapName: string;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export type GameLibraryGame = 't5' | 't6';
|
||||
export type GameLibraryState = 'importing' | 'ready' | 'failed';
|
||||
export type LibraryCacheState = 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated';
|
||||
|
||||
export interface GameFileEntry { path: string; size: number; sha256: string }
|
||||
export interface HostLibraryCache {
|
||||
hostId: string; libraryVersionId: string; status: LibraryCacheState;
|
||||
bytesDownloaded: number; bytesTotal: number; manifestSha256: string | null;
|
||||
error: string | null; updatedAt: string; operationId: string | null;
|
||||
}
|
||||
export interface GameLibraryVersion {
|
||||
id: string; libraryId: string; game: GameLibraryGame; version: string;
|
||||
description: string; dlc: string[]; state: GameLibraryState; sizeBytes: number;
|
||||
fileCount: number; manifestSha256: string | null; files: GameFileEntry[];
|
||||
createdAt: string; publishedAt: string | null; storageLocation: string;
|
||||
sourceHostId: string; sourceVolume: string; error: string | null; operationId: string | null;
|
||||
caches: HostLibraryCache[];
|
||||
}
|
||||
export interface GameLibrary {
|
||||
id: string; name: string; game: GameLibraryGame; description: string;
|
||||
createdAt: string; versions: GameLibraryVersion[];
|
||||
}
|
||||
|
||||
export function formatGameBytes(bytes: number): string {
|
||||
if (!bytes) return '0 B';
|
||||
const unit = Math.min(4, Math.floor(Math.log(bytes) / Math.log(1024)));
|
||||
return `${(bytes / 1024 ** unit).toFixed(unit ? 1 : 0)} ${['B', 'KiB', 'MiB', 'GiB', 'TiB'][unit]}`;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export type HostMode = 'active' | 'draining' | 'disabled';
|
||||
export type HostReadiness = 'preparing' | 'ready' | 'degraded' | 'draining' | 'disabled';
|
||||
export type HostOperationState = 'pending' | 'accepted' | 'running' | 'completed' | 'failed' | 'unknown';
|
||||
|
||||
export interface HostLibrary { id: string; state: 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated'; progress?: number; error?: string }
|
||||
export interface HostInventory {
|
||||
hostname?: string; agentVersion?: string; protocolVersion?: number; os?: string; architecture?: string;
|
||||
cpu?: string; cpuCount?: number; memoryTotal?: number; memoryAvailable?: number;
|
||||
storageTotal?: number; storageAvailable?: number; dockerVersion?: string; dockerRunning?: boolean;
|
||||
directoriesWritable?: boolean; runtimeImages?: string[]; libraries?: HostLibrary[];
|
||||
currentOperation?: string | null; [key: string]: unknown;
|
||||
}
|
||||
export interface Host {
|
||||
id: string; name: string; hostname: string; mode: HostMode; portMin: number; portMax: number;
|
||||
bindAddress: string; publicAddress: string; minimumFreeBytes: number; createdAt: string;
|
||||
lastHeartbeat: string | null; connected: boolean; connectionState: 'online' | 'offline';
|
||||
readiness: HostReadiness; readinessReasons: string[]; inventory: HostInventory;
|
||||
enrolled: boolean; credentialActive: boolean;
|
||||
}
|
||||
export interface HostOperation {
|
||||
id: string; hostId: string; kind: string; instanceId: string | null; generation: number | null;
|
||||
payload: Record<string, unknown>; state: HostOperationState; result: unknown; error: string | null;
|
||||
createdAt: string; updatedAt: string;
|
||||
}
|
||||
|
||||
export interface NativeDeploymentSelection {
|
||||
hostId: string; libraryIds: Partial<Record<'t5' | 't6', string>>; image: string;
|
||||
bindAddress?: string; publicAddress?: string; publicPort?: number;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export const provisioningPhases = ['requested', 'eligibility', 'startup-queue',
|
||||
'verify-map', 'verify-plutonium', 'verify-stopped'] as const;
|
||||
export type ProvisioningPhase = typeof provisioningPhases[number];
|
||||
export type EvidenceResult = 'pass' | 'waiting' | 'fail' | 'unknown';
|
||||
export type EvidenceSource = 'komodo' | 'rcon' | 'telemetry' | 'combined' | 'manifest' | null;
|
||||
export type EvidenceSource = 'komodo' | 'agent' | 'rcon' | 'telemetry' | 'combined' | 'manifest' | null;
|
||||
|
||||
export interface ProvisioningCheck {
|
||||
key: 'container' | 'game' | 'map' | 'plutonium-release' | 'plutonium-version' | 'occupancy' | 'stopped';
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { UserRole } from '../../auth.js';
|
||||
|
||||
export type RoutePermission = 'public' | 'machine' | 'authenticated' | 'viewer' | 'operator' | 'admin';
|
||||
|
||||
const within = (route: string, base: string): boolean => route === base || route.startsWith(base + '/');
|
||||
const readMethod = (method: string): boolean => method === 'GET' || method === 'HEAD';
|
||||
|
||||
/** Classify SvelteKit's resolved route ID, never a user-supplied URL pathname.
|
||||
* Route IDs preserve the same policy for page-data requests and encoded URLs.
|
||||
* Machine endpoints enforce their own host/enrollment/telemetry credentials.
|
||||
*/
|
||||
export function routePermission(routeId: string | null, method: string): RoutePermission {
|
||||
method = method.toUpperCase();
|
||||
const reading = readMethod(method);
|
||||
if (routeId === null) return reading ? 'viewer' : 'operator';
|
||||
// These cannot occur in this application's resolved route IDs. Fail closed
|
||||
// if a future caller accidentally supplies a raw or noncanonical pathname.
|
||||
if (!routeId.startsWith('/') || /[%\\?#\x00-\x1f\x7f]/.test(routeId)
|
||||
|| routeId !== '/' && routeId.split('/').slice(1).some(part => !part || part === '.' || part === '..')) return 'admin';
|
||||
|
||||
if (within(routeId, '/api/agent')) return 'machine';
|
||||
if (routeId === '/api/telemetry' && method === 'POST') return 'machine';
|
||||
if ((routeId === '/login' || routeId === '/setup') && reading) return 'public';
|
||||
if ((routeId === '/api/auth/login' || routeId === '/api/auth/setup') && method === 'POST') return 'public';
|
||||
if (routeId === '/account' || ['/api/auth/me', '/api/auth/logout', '/api/auth/password'].includes(routeId)) return 'authenticated';
|
||||
|
||||
if (within(routeId, '/api/users') || within(routeId, '/settings') || within(routeId, '/api/auth')
|
||||
|| within(routeId, '/api/server-keys') || within(routeId, '/server-keys') || within(routeId, '/keys')) return 'admin';
|
||||
|
||||
if (!reading && ['/api/hosts', '/api/settings', '/api/game-files'].some(base => within(routeId, base))) return 'admin';
|
||||
// These endpoints mix operational actions with global policy and schedule
|
||||
// changes. Per-incident/job actions remain available to operators.
|
||||
if (!reading && ['/api/maintenance', '/api/plutonium', '/api/watchdog'].includes(routeId)) return 'admin';
|
||||
|
||||
// Summaries remain visible to viewers; raw configuration, historical file
|
||||
// bytes, and unfiltered diagnostics may contain runtime credentials.
|
||||
if (routeId === '/configs/[id]' || routeId === '/api/configs/[id]' || routeId === '/api/configs/inspect'
|
||||
|| routeId === '/api/campaigns/[id]/file/[fileId]' || routeId === '/api/deployments/[id]/file/[fileId]'
|
||||
|| routeId === '/api/servers/[name]/logs/[file]' || routeId === '/api/servers/[name]/telemetry-requests') return 'operator';
|
||||
|
||||
return reading ? 'viewer' : 'operator';
|
||||
}
|
||||
|
||||
/** A browser role never substitutes for a machine credential. */
|
||||
export function roleAllows(role: UserRole, permission: RoutePermission): boolean {
|
||||
if (!['viewer', 'operator', 'admin'].includes(role)) return false;
|
||||
if (permission === 'machine') return false;
|
||||
if (permission === 'public' || permission === 'authenticated' || permission === 'viewer') return true;
|
||||
if (permission === 'operator') return role === 'operator' || role === 'admin';
|
||||
return permission === 'admin' && role === 'admin';
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { error, json, redirect, type Handle } from '@sveltejs/kit';
|
||||
import { readSession, readSessionHash, recordAudit, setupRequired } from './index.js';
|
||||
import { authBody, authError, requireSameOrigin, requireUserTransport, sessionCookie } from './http.js';
|
||||
import { roleAllows, routePermission, type RoutePermission } from './access.js';
|
||||
|
||||
function publicAsset(routeId: string | null, path: string, method: string) {
|
||||
return routeId === null && ['GET','HEAD'].includes(method) &&
|
||||
(path.startsWith('/_app/') || path === '/favicon.svg' || ['/agent/ec-agent.py','/agent/install.sh','/agent/manifest.json'].includes(path));
|
||||
}
|
||||
|
||||
/** Streaming responses must lose access when the underlying session does. */
|
||||
export function protectEventStream(response: Response, tokenHash: string, permission: RoutePermission, signal: AbortSignal): Response {
|
||||
if (!response.body) return response;
|
||||
const reader = response.body.getReader();
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
let closed = false;
|
||||
let closeStream: () => void = () => {};
|
||||
const cleanup = () => { if (timer) clearInterval(timer); signal.removeEventListener('abort', closeStream); };
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
closeStream = () => {
|
||||
if (closed) return; closed = true; cleanup();
|
||||
void reader.cancel().catch(() => {}); controller.close();
|
||||
};
|
||||
timer = setInterval(() => {
|
||||
try {
|
||||
const current = readSessionHash(tokenHash);
|
||||
if (!current || current.user.mustChangePassword || !roleAllows(current.user.role,permission)) closeStream();
|
||||
} catch { closeStream(); }
|
||||
},15_000);
|
||||
signal.addEventListener('abort',closeStream,{once:true});
|
||||
if (signal.aborted) { closeStream(); return; }
|
||||
},
|
||||
async pull(controller) {
|
||||
try {
|
||||
const next = await reader.read();
|
||||
if (closed) return;
|
||||
if (next.done) { closed=true; cleanup(); controller.close(); }
|
||||
else controller.enqueue(next.value);
|
||||
} catch (cause) { if (!closed) { closed=true; cleanup(); controller.error(cause); } }
|
||||
},
|
||||
cancel() { closed=true; cleanup(); return reader.cancel(); }
|
||||
});
|
||||
return new Response(stream,{status:response.status,statusText:response.statusText,headers:response.headers});
|
||||
}
|
||||
|
||||
export const authenticationHandle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.user = null; event.locals.sessionHash = null;
|
||||
const routeId = event.route.id;
|
||||
const method = event.request.method.toUpperCase();
|
||||
if (publicAsset(routeId,event.url.pathname,method)) return resolve(event);
|
||||
const permission = routePermission(routeId,method);
|
||||
if (!routeId?.startsWith('/api/agent/') && /^Bearer ec_(?:host|enroll)_/i.test(event.request.headers.get('authorization')??'')) {
|
||||
return json({error:'Host credentials only authorize the host agent API.'},{status:403});
|
||||
}
|
||||
if (permission === 'machine') return resolve(event);
|
||||
const api = routeId?.startsWith('/api/') || event.url.pathname.startsWith('/api/');
|
||||
try {
|
||||
requireUserTransport(event.url);
|
||||
if (!['GET','HEAD','OPTIONS'].includes(method)) requireSameOrigin(event.request,event.url);
|
||||
} catch (cause) { if (api) return authError(cause); throw cause; }
|
||||
|
||||
const session = readSession(event.cookies.get(sessionCookie(event.url)));
|
||||
event.locals.user = session?.user??null;
|
||||
event.locals.sessionHash = session?.tokenHash??null;
|
||||
const setup = setupRequired();
|
||||
if (permission !== 'public') {
|
||||
if (!session) {
|
||||
if (api) return json({error:setup?'Complete administrator setup before using the management API.':'Sign in to continue.',setupRequired:setup},{status:401,headers:{'Cache-Control':'no-store'}});
|
||||
redirect(303,setup?'/setup':`/login?next=${encodeURIComponent(event.url.pathname+event.url.search)}`);
|
||||
}
|
||||
if (session.user.mustChangePassword && permission !== 'authenticated') {
|
||||
if (api) return json({error:'Change your temporary password from Account before continuing.',mustChangePassword:true},{status:403,headers:{'Cache-Control':'no-store'}});
|
||||
redirect(303,'/account');
|
||||
}
|
||||
if (!roleAllows(session.user.role,permission)) {
|
||||
const message = permission==='admin'?'Administrator access is required.':'Operator access is required for this action.';
|
||||
if (api) return json({error:message},{status:403,headers:{'Cache-Control':'no-store'}});
|
||||
error(403,message);
|
||||
}
|
||||
// Launch APIs can also save credentials. Operators may launch with saved
|
||||
// keys, but only administrators may introduce or replace server keys.
|
||||
if (session.user.role === 'operator' && method === 'POST' && ['/api/servers','/api/deployment-collections'].includes(routeId??'')) {
|
||||
try {
|
||||
const body = await authBody(event.request.clone(),1024*1024);
|
||||
if (body.plutoKey || (body.keys && typeof body.keys==='object' && Object.values(body.keys).some(Boolean))) {
|
||||
return json({error:'An administrator must save server keys before an operator launches these servers.'},{status:403});
|
||||
}
|
||||
} catch (cause) { return authError(cause); }
|
||||
}
|
||||
} else if (['/login','/setup'].includes(routeId??'')) {
|
||||
if (session) redirect(303,session.user.mustChangePassword?'/account':'/');
|
||||
if (routeId==='/setup'&&!setup) redirect(303,'/login');
|
||||
if (routeId==='/login'&&setup) redirect(303,'/setup');
|
||||
}
|
||||
|
||||
let response = await resolve(event);
|
||||
if (session && !['GET','HEAD','OPTIONS'].includes(method) && response.status < 400) {
|
||||
recordAudit(session.user.id,'request',routeId,{method,status:response.status,path:event.url.pathname});
|
||||
}
|
||||
if (session && response.headers.get('content-type')?.startsWith('text/event-stream')) {
|
||||
response = protectEventStream(response,session.tokenHash,permission,event.request.signal);
|
||||
}
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set('Cache-Control','no-store');
|
||||
headers.set('Vary',[headers.get('Vary'),'Cookie'].filter(Boolean).join(', '));
|
||||
headers.set('Referrer-Policy','same-origin');
|
||||
headers.set('X-Content-Type-Options','nosniff');
|
||||
headers.set('X-Frame-Options','DENY');
|
||||
return new Response(response.body,{status:response.status,statusText:response.statusText,headers});
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { error, isHttpError, json, type Cookies } from '@sveltejs/kit';
|
||||
import { AuthError, SESSION_COOKIE, type AuthResult } from './index.js';
|
||||
import type { AuthUser } from '../../auth.js';
|
||||
|
||||
export function sessionCookie(url: URL): string { return url.protocol === 'https:' ? SESSION_COOKIE : 'ec_session_dev'; }
|
||||
export function requireUserTransport(url: URL): void {
|
||||
if (url.protocol === 'https:') return;
|
||||
if (process.env.EC_AUTH_ALLOW_HTTP === 'true' && ['localhost','127.0.0.1','[::1]','::1'].includes(url.hostname)) return;
|
||||
error(426, 'Operator login requires HTTPS. For local development only, set EC_AUTH_ALLOW_HTTP=true and use localhost.');
|
||||
}
|
||||
export function requireSameOrigin(request: Request, url: URL): void {
|
||||
if (request.headers.get('origin') !== url.origin || request.headers.get('sec-fetch-site') === 'cross-site') {
|
||||
error(403, 'This action requires a same-origin request.');
|
||||
}
|
||||
}
|
||||
export function sessionResponse(result: AuthResult, cookies: Cookies, url: URL) {
|
||||
cookies.set(sessionCookie(url), result.session.token, { path: '/', httpOnly: true, secure: url.protocol === 'https:',
|
||||
sameSite: 'lax', expires: new Date(result.session.expiresAt) });
|
||||
return json({ user: result.user }, { headers: { 'Cache-Control':'no-store' } });
|
||||
}
|
||||
export function requireUser(user: AuthUser | null): AuthUser {
|
||||
if (!user) throw new AuthError('Sign in to continue.',401);
|
||||
return user;
|
||||
}
|
||||
export async function authBody(request: Request, maxBytes = 8192): Promise<Record<string, unknown>> {
|
||||
if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) throw new AuthError('Send a JSON request.',415);
|
||||
if (Number(request.headers.get('content-length')) > maxBytes) throw new AuthError('Request is too large.',413);
|
||||
const reader = request.body?.getReader(); if (!reader) throw new AuthError('A JSON object is required.');
|
||||
const chunks: Uint8Array[] = []; let length = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
length += value.byteLength;
|
||||
if (length > maxBytes) { void reader.cancel().catch(() => {}); throw new AuthError('Request is too large.',413); }
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally { reader.releaseLock(); }
|
||||
let body: unknown;
|
||||
try { body = JSON.parse(Buffer.concat(chunks,length).toString('utf8')); } catch { throw new AuthError('Invalid JSON.'); }
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new AuthError('A JSON object is required.');
|
||||
return body as Record<string, unknown>;
|
||||
}
|
||||
export function authError(cause: unknown): Response {
|
||||
if (isHttpError(cause)) return json({ error: cause.body.message }, { status:cause.status, headers:{'Cache-Control':'no-store'} });
|
||||
const message = cause instanceof Error ? cause.message : 'Authentication request failed.';
|
||||
const status = cause instanceof AuthError ? cause.status : message.includes('busy') ? 429 : message.startsWith('Use a password') ? 400 : 500;
|
||||
return json({ error: status === 500 ? 'The authentication request could not be completed.' : message },
|
||||
{ status, headers:{'Cache-Control':'no-store',...(status===429?{'Retry-After':'900'}:{})} });
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { ensureAuthSchema } from './schema.js';
|
||||
import { hashPassword, verifyPassword } from './passwords.js';
|
||||
import type { AuthUser, UserRole } from '../../auth.js';
|
||||
|
||||
export const SESSION_COOKIE = '__Host-ec_session';
|
||||
export const SESSION_LIFETIME_MS = 12 * 60 * 60_000;
|
||||
export const SESSION_IDLE_MS = 30 * 60_000;
|
||||
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
|
||||
function database() { const handle = db(); ensureAuthSchema(handle); return handle; }
|
||||
type UserRow = { id: string; username: string; display_name: string; password_hash: string; role: UserRole; disabled: number;
|
||||
must_change_password: number; created_at: number; updated_at: number; last_login_at: number | null };
|
||||
export class AuthError extends Error {
|
||||
constructor(message: string, public status = 400) { super(message); }
|
||||
}
|
||||
function present(row: UserRow): AuthUser {
|
||||
return { id: row.id, username: row.username, displayName: row.display_name, role: row.role, disabled: !!row.disabled,
|
||||
mustChangePassword: !!row.must_change_password, createdAt: new Date(row.created_at).toISOString(),
|
||||
updatedAt: new Date(row.updated_at).toISOString(), lastLoginAt: row.last_login_at === null ? null : new Date(row.last_login_at).toISOString() };
|
||||
}
|
||||
function userRow(id: string) { return database().prepare('SELECT * FROM auth_users WHERE id=?').get(id) as UserRow | undefined; }
|
||||
export function getUser(id: string): AuthUser | null { const row = userRow(id); return row ? present(row) : null; }
|
||||
export function listUsers(): AuthUser[] { return (database().prepare('SELECT * FROM auth_users ORDER BY username').all() as UserRow[]).map(present); }
|
||||
export function setupRequired(): boolean { return !(database().prepare('SELECT 1 FROM auth_users LIMIT 1').get()); }
|
||||
function username(value: unknown): string {
|
||||
if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{2,63}$/.test(value)) throw new AuthError('Usernames need 3–64 letters, numbers, dots, underscores or hyphens.');
|
||||
return value.toLowerCase();
|
||||
}
|
||||
function displayName(value: unknown, fallback: string): string {
|
||||
const name = value === undefined ? fallback : value;
|
||||
if (typeof name !== 'string' || !name.trim() || name.length > 100 || /[\x00-\x1f\x7f]/.test(name)) throw new AuthError('Display name must contain 1–100 printable characters.');
|
||||
return name.trim();
|
||||
}
|
||||
function role(value: unknown): UserRole {
|
||||
if (!['admin','operator','viewer'].includes(value as string)) throw new AuthError('Choose administrator, operator or viewer.');
|
||||
return value as UserRole;
|
||||
}
|
||||
export function recordAudit(actorId: string | null, action: string, targetId: string | null = null, details: Record<string, unknown> = {}): void {
|
||||
database().prepare('INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(?,?,?,?,?)')
|
||||
.run(actorId, action, targetId, JSON.stringify(details), Date.now());
|
||||
}
|
||||
export function listAuthAudit() {
|
||||
return database().prepare(`SELECT a.id,a.actor_id AS actorId,u.username AS actor,a.action,a.target_id AS targetId,a.details,a.created_at AS createdAt
|
||||
FROM auth_audit a LEFT JOIN auth_users u ON u.id=a.actor_id ORDER BY a.id DESC LIMIT 200`).all();
|
||||
}
|
||||
|
||||
/** Persistent fixed windows are consumed before hashing, including failed setup. */
|
||||
export function consumeAuthAttempt(clientAddress: string, account: string, kind = 'login'): void {
|
||||
const now = Date.now(); const windowMs = 15 * 60_000;
|
||||
const ipKey = digest(`${kind}:ip:${clientAddress}`);
|
||||
const accountKey = digest(`${kind}:account:${account.toLowerCase()}`);
|
||||
database().transaction(() => {
|
||||
database().prepare('DELETE FROM auth_rate_limits WHERE window_start < ?').run(now - 24 * 60 * 60_000);
|
||||
for (const [key, limit] of [[ipKey, 30], [accountKey, 10]] as const) {
|
||||
const previous = database().prepare('SELECT window_start,attempts FROM auth_rate_limits WHERE key=?').get(key) as { window_start: number; attempts: number } | undefined;
|
||||
if (previous && previous.window_start > now - windowMs && previous.attempts >= limit) throw new AuthError('Too many attempts. Try again in 15 minutes.', 429);
|
||||
}
|
||||
for (const key of [ipKey, accountKey]) database().prepare(`INSERT INTO auth_rate_limits(key,window_start,attempts) VALUES(?,?,1)
|
||||
ON CONFLICT(key) DO UPDATE SET attempts=CASE WHEN window_start <= ? THEN 1 ELSE attempts+1 END,
|
||||
window_start=CASE WHEN window_start <= ? THEN excluded.window_start ELSE window_start END`).run(key, now, now - windowMs, now - windowMs);
|
||||
}).immediate();
|
||||
}
|
||||
|
||||
function insertUser(input: { username: string; displayName: string; role: UserRole; passwordHash: string; mustChangePassword: boolean }): AuthUser {
|
||||
if (database().prepare('SELECT 1 FROM auth_users WHERE username=?').get(input.username)) throw new AuthError('That username is already in use.', 409);
|
||||
const id = randomUUID(); const now = Date.now();
|
||||
database().prepare(`INSERT INTO auth_users(id,username,display_name,password_hash,role,must_change_password,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`)
|
||||
.run(id,input.username,input.displayName,input.passwordHash,input.role,Number(input.mustChangePassword),now,now);
|
||||
return getUser(id)!;
|
||||
}
|
||||
export interface AuthResult { user: AuthUser; session: { token: string; expiresAt: number } }
|
||||
export async function bootstrapAdmin(input: Record<string, unknown>, clientAddress: string): Promise<AuthResult> {
|
||||
consumeAuthAttempt(clientAddress, 'bootstrap', 'setup');
|
||||
if (!setupRequired()) throw new AuthError('Initial setup is already complete.', 409);
|
||||
const expected = process.env.EC_SETUP_TOKEN;
|
||||
if (!expected || expected.length < 32) throw new AuthError('The controller owner must configure EC_SETUP_TOKEN with at least 32 characters before setup.', 503);
|
||||
if (typeof input.token !== 'string' || !timingSafeEqual(Buffer.from(digest(input.token), 'hex'), Buffer.from(digest(expected), 'hex'))) throw new AuthError('Invalid setup token.', 401);
|
||||
const name = username(input.username); const label = displayName(input.displayName, name);
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
return database().transaction(() => {
|
||||
if (!setupRequired()) throw new AuthError('Initial setup is already complete.', 409);
|
||||
const user = insertUser({ username: name, displayName: label, passwordHash, role: 'admin', mustChangePassword: false });
|
||||
recordAudit(user.id, 'setup', user.id);
|
||||
return { user, session: createSession(user.id) };
|
||||
}).immediate();
|
||||
}
|
||||
export async function authenticatePassword(input: Record<string, unknown>, clientAddress: string): Promise<AuthResult> {
|
||||
const name = typeof input.username === 'string' ? input.username.slice(0,64).toLowerCase() : '';
|
||||
consumeAuthAttempt(clientAddress, name);
|
||||
const row = database().prepare('SELECT * FROM auth_users WHERE username=?').get(name) as UserRow | undefined;
|
||||
const valid = await verifyPassword(input.password, row?.password_hash ?? null);
|
||||
const result = database().transaction(() => {
|
||||
const current = row ? userRow(row.id) : undefined;
|
||||
if (!valid || !current || current.disabled || current.password_hash !== row?.password_hash) {
|
||||
recordAudit(null, 'login.failed'); return null;
|
||||
}
|
||||
database().prepare('UPDATE auth_users SET last_login_at=? WHERE id=?').run(Date.now(), current.id);
|
||||
recordAudit(current.id, 'login', current.id);
|
||||
return { user: getUser(current.id)!, session: createSession(current.id) };
|
||||
}).immediate();
|
||||
if (!result) throw new AuthError('Invalid username or password.', 401);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createSession(userId: string): { token: string; expiresAt: number } {
|
||||
const now = Date.now(); const user = userRow(userId);
|
||||
if (!user || user.disabled) throw new AuthError('Account is unavailable.', 401);
|
||||
const token = randomBytes(32).toString('base64url'); const expiresAt = now + SESSION_LIFETIME_MS;
|
||||
database().prepare('DELETE FROM auth_sessions WHERE expires_at <= ? OR last_seen_at <= ?').run(now,now-SESSION_IDLE_MS);
|
||||
database().prepare('INSERT INTO auth_sessions(token_hash,user_id,created_at,expires_at,last_seen_at) VALUES(?,?,?,?,?)').run(digest(token),userId,now,expiresAt,now);
|
||||
return { token, expiresAt };
|
||||
}
|
||||
export function readSession(token: string | undefined, touch = true): { user: AuthUser; tokenHash: string; expiresAt: number } | null {
|
||||
if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
|
||||
return readSessionHash(digest(token), touch);
|
||||
}
|
||||
export function readSessionHash(tokenHash: string, touch = false): { user: AuthUser; tokenHash: string; expiresAt: number } | null {
|
||||
const now = Date.now();
|
||||
const row = database().prepare(`SELECT u.*,s.expires_at,s.last_seen_at FROM auth_sessions s JOIN auth_users u ON u.id=s.user_id
|
||||
WHERE s.token_hash=? AND s.expires_at>? AND s.last_seen_at>? AND u.disabled=0`).get(tokenHash,now,now-SESSION_IDLE_MS) as (UserRow & { expires_at: number; last_seen_at: number }) | undefined;
|
||||
if (!row) return null;
|
||||
if (touch && row.last_seen_at < now-60_000) database().prepare('UPDATE auth_sessions SET last_seen_at=? WHERE token_hash=?').run(now,tokenHash);
|
||||
return { user: present(row), tokenHash, expiresAt: row.expires_at };
|
||||
}
|
||||
export function revokeSession(token: string | undefined): void { if (token) database().prepare('DELETE FROM auth_sessions WHERE token_hash=?').run(digest(token)); }
|
||||
export function revokeUserSessions(actorId: string, userId: string): void {
|
||||
database().transaction(() => {
|
||||
requireAdministrator(actorId);
|
||||
if (!userRow(userId)) throw new AuthError('User not found.',404);
|
||||
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId);
|
||||
recordAudit(actorId,'sessions.revoked',userId);
|
||||
})();
|
||||
}
|
||||
function requireAdministrator(actorId: string) {
|
||||
const actor = userRow(actorId);
|
||||
if (!actor || actor.disabled || actor.role !== 'admin' || actor.must_change_password) throw new AuthError('Administrator access is required.',403);
|
||||
}
|
||||
export async function createUser(actorId: string, input: Record<string, unknown>): Promise<AuthUser> {
|
||||
requireAdministrator(actorId);
|
||||
const name = username(input.username), label = displayName(input.displayName,name), userRole = role(input.role);
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
return database().transaction(() => {
|
||||
requireAdministrator(actorId);
|
||||
const user = insertUser({ username:name,displayName:label,role:userRole,passwordHash,mustChangePassword:true });
|
||||
recordAudit(actorId,'user.created',user.id,{role:userRole}); return user;
|
||||
}).immediate();
|
||||
}
|
||||
export async function updateUser(actorId: string, id: string, input: Record<string, unknown>): Promise<AuthUser> {
|
||||
requireAdministrator(actorId);
|
||||
if (input.disabled !== undefined && typeof input.disabled !== 'boolean') throw new AuthError('Disabled must be true or false.');
|
||||
if (input.password !== undefined && actorId === id) throw new AuthError('Change your own password from Account using your current password.');
|
||||
const passwordHash = input.password === undefined ? null : await hashPassword(input.password);
|
||||
return database().transaction(() => {
|
||||
requireAdministrator(actorId);
|
||||
const current = userRow(id); if (!current) throw new AuthError('User not found.',404);
|
||||
const nextRole = input.role === undefined ? current.role : role(input.role);
|
||||
const disabled = input.disabled === undefined ? current.disabled : Number(input.disabled);
|
||||
const label = displayName(input.displayName,current.display_name);
|
||||
if (current.role === 'admin' && !current.disabled && (nextRole !== 'admin' || disabled)) {
|
||||
const other = database().prepare("SELECT 1 FROM auth_users WHERE role='admin' AND disabled=0 AND id!=? LIMIT 1").get(id);
|
||||
if (!other) throw new AuthError('Keep at least one enabled administrator.',409);
|
||||
}
|
||||
database().prepare(`UPDATE auth_users SET display_name=?,role=?,disabled=?,password_hash=?,must_change_password=?,updated_at=? WHERE id=?`)
|
||||
.run(label,nextRole,disabled,passwordHash??current.password_hash,passwordHash?1:current.must_change_password,Date.now(),id);
|
||||
if (passwordHash || disabled !== current.disabled || nextRole !== current.role) database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(id);
|
||||
recordAudit(actorId,'user.updated',id,{role:nextRole,disabled:!!disabled,passwordReset:!!passwordHash});
|
||||
return getUser(id)!;
|
||||
}).immediate();
|
||||
}
|
||||
export async function changePassword(userId: string, currentPassword: unknown, newPassword: unknown): Promise<AuthResult> {
|
||||
const original = userRow(userId);
|
||||
if (!original || original.disabled || !await verifyPassword(currentPassword,original.password_hash)) throw new AuthError('Current password is incorrect.',400);
|
||||
if (currentPassword === newPassword) throw new AuthError('Choose a different password.');
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
return database().transaction(() => {
|
||||
const current = userRow(userId);
|
||||
if (!current || current.disabled || current.password_hash !== original.password_hash) throw new AuthError('Account changed during password verification. Sign in again.',409);
|
||||
database().prepare('UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=?').run(passwordHash,Date.now(),userId);
|
||||
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId);
|
||||
recordAudit(userId,'password.changed',userId);
|
||||
return { user: getUser(userId)!, session: createSession(userId) };
|
||||
}).immediate();
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
// OWASP's scrypt baseline: 128 MiB, r=8, p=1. Bound concurrent work so
|
||||
// unauthenticated password attempts cannot allocate unlimited memory.
|
||||
const parameters = { N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
|
||||
let active = 0;
|
||||
|
||||
/** @param {unknown} password */
|
||||
export function validatePassword(password) {
|
||||
if (typeof password !== 'string' || [...password].length < 15 || [...password].length > 128) {
|
||||
throw new Error('Use a password or passphrase between 15 and 128 characters.');
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
/** @param {string} password @param {string} salt @returns {Promise<Buffer>} */
|
||||
function derive(password, salt) {
|
||||
if (active >= 2) return Promise.reject(new Error('Password service is busy. Try again shortly.'));
|
||||
active++;
|
||||
return new Promise((resolve, reject) => {
|
||||
scrypt(password, salt, 64, parameters, (error, result) => {
|
||||
active--;
|
||||
if (error) reject(error); else resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {unknown} password */
|
||||
export async function hashPassword(password) {
|
||||
const validated = validatePassword(password);
|
||||
const salt = randomBytes(32).toString('hex');
|
||||
const key = await derive(validated, salt);
|
||||
return `scrypt$131072$8$1$${salt}$${key.toString('hex')}`;
|
||||
}
|
||||
|
||||
/** @param {unknown} password @param {string | null} encoded */
|
||||
export async function verifyPassword(password, encoded) {
|
||||
if (typeof password !== 'string' || [...password].length > 128) return false;
|
||||
const parts = encoded?.split('$');
|
||||
const valid = parts?.length === 6 && parts[0] === 'scrypt' && parts[1] === '131072' && parts[2] === '8' && parts[3] === '1'
|
||||
&& /^[a-f0-9]{64}$/.test(parts[4]) && /^[a-f0-9]{128}$/.test(parts[5]);
|
||||
// Missing accounts perform the same expensive operation as real accounts.
|
||||
const result = await derive(password, valid ? parts[4] : '0'.repeat(64));
|
||||
return timingSafeEqual(result, Buffer.from(valid ? parts[5] : '0'.repeat(128), 'hex')) && !!valid;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
const initialized = new WeakSet<Database.Database>();
|
||||
export function ensureAuthSchema(handle: Database.Database): void {
|
||||
if (initialized.has(handle)) return;
|
||||
handle.exec(`
|
||||
CREATE TABLE IF NOT EXISTS auth_users (
|
||||
id TEXT PRIMARY KEY, username TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
||||
display_name TEXT NOT NULL, password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('admin','operator','viewer')),
|
||||
disabled INTEGER NOT NULL DEFAULT 0, must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS auth_sessions_user ON auth_sessions(user_id);
|
||||
CREATE TABLE IF NOT EXISTS auth_rate_limits (
|
||||
key TEXT PRIMARY KEY, window_start INTEGER NOT NULL, attempts INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS auth_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, actor_id TEXT, action TEXT NOT NULL,
|
||||
target_id TEXT, details TEXT NOT NULL, created_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
initialized.add(handle);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export type TelemetryAuthentication =
|
||||
| { ok: true }
|
||||
| { ok: false; status: 401 | 503; error: string };
|
||||
|
||||
const tokenPattern = /^[A-Za-z0-9._~+\/-]+=*$/;
|
||||
const validToken = (value: string): boolean => value.length >= 32 && value.length <= 512 && tokenPattern.test(value);
|
||||
const hash = (value: string): Buffer => createHash('sha256').update(value, 'utf8').digest();
|
||||
|
||||
/** Machine telemetry has its own credential; browser sessions never authorize it. */
|
||||
export function authenticateTelemetry(request: Request): TelemetryAuthentication {
|
||||
const expected = process.env.EC_TELEMETRY_TOKEN ?? '';
|
||||
if (!validToken(expected)) {
|
||||
return { ok: false, status: 503, error: 'Game telemetry authentication is not configured. Set EC_TELEMETRY_TOKEN to a secret of at least 32 characters.' };
|
||||
}
|
||||
|
||||
const authorization = request.headers.get('authorization');
|
||||
const custom = request.headers.get('x-ec-telemetry-token');
|
||||
const bearer = authorization?.match(/^Bearer ([A-Za-z0-9._~+\/-]+=*)$/i)?.[1];
|
||||
const denied = (): TelemetryAuthentication => ({ ok: false, status: 401, error: 'A valid game telemetry token is required.' });
|
||||
|
||||
// Reject malformed/ambiguous credentials rather than falling back to a
|
||||
// second header. A shared secret must never come from a URL, cookie or body.
|
||||
if (authorization !== null && !bearer) return denied();
|
||||
if (custom !== null && !validToken(custom)) return denied();
|
||||
if (bearer !== undefined && !validToken(bearer)) return denied();
|
||||
const supplied = bearer ?? custom;
|
||||
if (!supplied) return denied();
|
||||
const suppliedHash = hash(supplied);
|
||||
if (bearer !== undefined && custom !== null && !timingSafeEqual(suppliedHash, hash(custom))) return denied();
|
||||
if (!timingSafeEqual(suppliedHash, hash(expected))) return denied();
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
`);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { emptyMetrics } from '../fleet/beta-evaluation.js';
|
||||
import { backfillLeaderboard } from '../fleet/leaderboard-ingest.js';
|
||||
import { initializeFleetSettings } from './fleet-settings.js';
|
||||
import { migrateServerHistory } from './server-history.js';
|
||||
import { migrateNativeInstances } from '../backends/schema.js';
|
||||
|
||||
let _db: Database.Database | null = null;
|
||||
|
||||
@@ -366,6 +367,7 @@ export function db(): Database.Database {
|
||||
]);
|
||||
backfillLeaderboard(handle);
|
||||
initializeFleetSettings(handle);
|
||||
migrateNativeInstances(handle);
|
||||
} catch (err) {
|
||||
handle.close();
|
||||
throw err;
|
||||
|
||||
@@ -10,8 +10,10 @@ async function exclusive<T>(id: number, action: () => Promise<T>): Promise<T> {
|
||||
try { return await action(); } finally { operations.delete(id); }
|
||||
}
|
||||
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { readFile, exists } from '../backends/files.js';
|
||||
import { getHostOperation } from '../hosts/index.js';
|
||||
|
||||
import { join, posix } from 'node:path';
|
||||
import { betaArchiveOf, configFileOf, destOfKind, type ArtifactKind } from '../config.js';
|
||||
import { db } from '../db/index.js';
|
||||
@@ -204,6 +206,14 @@ export class BetaError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function assertNativeActivationSettled(betaId: number): void {
|
||||
const pending = db().prepare(`SELECT n.operation_id FROM native_provisioning n
|
||||
JOIN provisioning_sources s ON s.operation_id=n.deployment_id
|
||||
WHERE s.kind='beta' AND s.source_id=? AND n.operation_id IS NOT NULL
|
||||
AND n.files_json IS NOT NULL AND n.applied_at IS NULL`).all(String(betaId)) as { operation_id: string }[];
|
||||
if (pending.some(row => getHostOperation(row.operation_id)?.state !== 'failed')) throw new BetaError('The host is still reconciling file activation. Wait for its durable before images before promoting or rolling back.', 'conflict');
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
@@ -563,6 +573,7 @@ export async function rollbackBeta(betaId: number, options: { restart?: 'manual'
|
||||
return exclusive(betaId, () => rollbackBetaInternal(betaId, options));
|
||||
}
|
||||
async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual' | 'when-empty' }): Promise<RollbackResult> {
|
||||
assertNativeActivationSettled(betaId);
|
||||
const beta = getBeta(betaId);
|
||||
if (!beta) throw new BetaError(`no such beta (id=${betaId})`, 'no-such-beta');
|
||||
if (beta.state !== 'running' && beta.state !== 'ready-promote') {
|
||||
@@ -658,7 +669,7 @@ async function rollbackBetaInternal(betaId: number, options: { restart?: 'manual
|
||||
|
||||
// Wipe archive + close out beta.
|
||||
const archive = betaArchiveOf(betaId);
|
||||
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
|
||||
if (await exists(archive)) await rm(archive, { recursive: true, force: true });
|
||||
|
||||
// Per-version test-failed transition. Skip a version if (a) another beta
|
||||
// still gates it at ready-promote/promoted, or (b) it's the currently-
|
||||
@@ -764,6 +775,7 @@ export async function promoteBeta(betaId: number, reviewedToken?: string): Promi
|
||||
return exclusive(betaId, () => promoteBetaInternal(betaId, reviewedToken));
|
||||
}
|
||||
async function promoteBetaInternal(betaId: number, reviewedToken?: string): Promise<PromoteResult> {
|
||||
assertNativeActivationSettled(betaId);
|
||||
const test = storedPlan(betaId);
|
||||
if (test && test.outcome !== 'passed') throw new BetaError('Test plan has not passed', 'not-ready-promote');
|
||||
const beta = getBeta(betaId);
|
||||
@@ -842,7 +854,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
||||
// Non-beta matching-scope server: current on-disk file (if any)
|
||||
// is the "before" state for a promote-then-rollback round trip.
|
||||
const targetPath = join(dest, script.target_rel_path);
|
||||
if (existsSync(targetPath)) {
|
||||
if (await exists(targetPath)) {
|
||||
const priorContent = await readFile(targetPath);
|
||||
const inv = getServerScript(name, script.target_rel_path);
|
||||
plan = {
|
||||
@@ -906,13 +918,13 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
||||
hadFile = true;
|
||||
}
|
||||
}
|
||||
if (!betaServerSet.has(name) && existsSync(targetPath)) {
|
||||
if (!betaServerSet.has(name) && await exists(targetPath)) {
|
||||
// Capture an initial preview; activation records the actual before image.
|
||||
priorContent = await readFile(targetPath);
|
||||
priorSha = sha256Buffer(priorContent);
|
||||
hadFile = true;
|
||||
}
|
||||
const requiresActivation = !betaServerSet.has(name) || !existsSync(targetPath)
|
||||
const requiresActivation = !betaServerSet.has(name) || !await exists(targetPath)
|
||||
|| sha256Buffer(await readFile(targetPath)) !== composedSha;
|
||||
cfgPlans.push({ server: name, priorContent, priorSha, composedContent: composedBuf, composedSha, hadFile, requiresActivation });
|
||||
}
|
||||
@@ -1038,7 +1050,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
|
||||
db().prepare('DELETE FROM beta_file_snapshots WHERE beta_id = ?').run(betaId);
|
||||
|
||||
const archive = betaArchiveOf(betaId);
|
||||
if (existsSync(archive)) await rm(archive, { recursive: true, force: true });
|
||||
if (await exists(archive)) await rm(archive, { recursive: true, force: true });
|
||||
|
||||
const landed = Array.from(new Set([
|
||||
...plans.map((p) => p.server),
|
||||
|
||||
@@ -11,6 +11,8 @@ import { getServerKey } from './server-keys.js';
|
||||
import { serverObservation } from './status.js';
|
||||
import { activeBetaRemovalBlocker, getServer } from './servers.js';
|
||||
import { parseServerName, zombieMapBySlug, zombieMaps } from './map-aliases.js';
|
||||
import { nativeSelection, nextNativePort } from '../provisioning/native.js';
|
||||
import { nativeInstance } from '../backends/native.js';
|
||||
|
||||
function coverage(entries: BlueprintEntry[]) {
|
||||
type Server = { name: string; game: string | null; map_slug: string | null; map: string | null };
|
||||
@@ -76,18 +78,25 @@ function allocate(entries: BlueprintEntry[], releasingServers: string[] = []): C
|
||||
const base = `${entry.recipe.game}_zombie_${entry.recipe.mapSlug}`;
|
||||
const lobby = nextLobbyNumber(base, names);
|
||||
const name = lobby === null ? null : `${base}_${lobby}`;
|
||||
const port = nextAvailablePort(ports, releasingServers);
|
||||
const port = entry.recipe.native ? nextNativePort(entry.recipe.native, ports) : nextAvailablePort(ports, releasingServers);
|
||||
if (name) names.push(name);
|
||||
if (port !== null) ports.push(port);
|
||||
return { entryIndex, name, port, hasSavedKey: !!name && !!getServerKey(name) };
|
||||
}));
|
||||
}
|
||||
|
||||
function blockersFor(servers: CollectionPreview['servers']): string[] {
|
||||
function blockersFor(servers: CollectionPreview['servers'], entries: BlueprintEntry[] = []): string[] {
|
||||
const blockers: string[] = [];
|
||||
if (!servers.length) return blockers;
|
||||
if (!komodoConfigured() || !config.komodo.targetServer) blockers.push('Configure Komodo credentials and a target host before launching.');
|
||||
if (!rconConfigured()) blockers.push('Configure RCON_PASSWORD so every new server can be verified.');
|
||||
if (entries.some(entry => entry.recipe.native)) {
|
||||
for (const entry of entries) {
|
||||
try { nativeSelection(entry.recipe.native as unknown as Record<string, unknown>, entry.recipe.game); }
|
||||
catch (error) { blockers.push((error as Error).message); }
|
||||
}
|
||||
} else {
|
||||
if (!komodoConfigured() || !config.komodo.targetServer) blockers.push('Select and prepare a host before launching.');
|
||||
if (!rconConfigured()) blockers.push('Configure RCON_PASSWORD so every new server can be verified.');
|
||||
}
|
||||
if (servers.some(server => !server.name)) blockers.push('There are not enough free lobbies for one or more maps (20 per map). Reduce their counts or free capacity.');
|
||||
if (servers.some(server => server.port === null)) blockers.push(`There are not enough free game ports in ${config.portRange.min}–${config.portRange.max} for the whole collection.`);
|
||||
return blockers;
|
||||
@@ -95,7 +104,7 @@ function blockersFor(servers: CollectionPreview['servers']): string[] {
|
||||
|
||||
function removalBlockersFor(removals: CollectionPreview['removals']): string[] {
|
||||
const blockers: string[] = [];
|
||||
if (removals.length && (!komodoConfigured() || !rconConfigured())) blockers.push('Configure Komodo and RCON before removing servers.');
|
||||
if (removals.some(server => !nativeInstance(server.name)) && (!komodoConfigured() || !rconConfigured())) blockers.push('Configure the legacy backend before removing its servers.');
|
||||
for (const server of removals) {
|
||||
const blocker = activeBetaRemovalBlocker(server.name);
|
||||
if (blocker) blockers.push(blocker);
|
||||
@@ -103,14 +112,21 @@ function removalBlockersFor(removals: CollectionPreview['removals']): string[] {
|
||||
return blockers;
|
||||
}
|
||||
|
||||
export function previewCollection(blueprintId: unknown, mode: unknown = 'fill'): CollectionPreview {
|
||||
export function previewCollection(blueprintId: unknown, mode: unknown = 'fill', selection?: Record<string, unknown>): CollectionPreview {
|
||||
if (typeof blueprintId !== 'string' || !blueprintId) throw new Error('Choose a collection to launch.');
|
||||
if (mode !== 'fill' && mode !== 'exact') throw new Error('Choose fill or exact collection mode.');
|
||||
return db().transaction((): CollectionPreview => {
|
||||
const { entries, blueprintName } = blueprintEntries(blueprintId);
|
||||
if (selection?.hostId !== undefined || !config.komodo.address) {
|
||||
const libraries = selection?.libraryIds as Record<string, unknown> | undefined;
|
||||
for (const entry of entries) {
|
||||
entry.recipe.native = nativeSelection({ ...selection, libraryId: libraries?.[entry.recipe.game] ?? selection?.libraryId }, entry.recipe.game);
|
||||
entry.recipe.image = entry.recipe.native.image!;
|
||||
}
|
||||
}
|
||||
const { counts, retainedServers, removals } = collectionPlan(entries, mode);
|
||||
const servers = allocate(entries, removals.map(server => server.name));
|
||||
const blockers = [...blockersFor(servers), ...removalBlockersFor(removals)];
|
||||
const blockers = [...blockersFor(servers, entries), ...removalBlockersFor(removals)];
|
||||
const id = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString();
|
||||
db().prepare('DELETE FROM server_collection_previews WHERE expires_at < ? AND collection_id IS NULL').run(new Date().toISOString());
|
||||
@@ -141,7 +157,7 @@ export function queueCollection(input: { previewId?: unknown; keys?: unknown }):
|
||||
const { retainedServers, removals } = collectionPlan(entries, preview.mode);
|
||||
const releasingServers = removals.map(server => server.name);
|
||||
const servers = allocate(entries, releasingServers);
|
||||
const blockers = [...blockersFor(servers), ...removalBlockersFor(removals)];
|
||||
const blockers = [...blockersFor(servers, entries), ...removalBlockersFor(removals)];
|
||||
if (blockers.length) throw new Error(blockers.join(' '));
|
||||
if (servers.length !== reviewed.length || servers.some((server, i) => server.entryIndex !== reviewed[i]?.entryIndex || server.name !== reviewed[i]?.name || server.port !== reviewed[i]?.port)) {
|
||||
throw new Error('Fleet counts, lobby or port availability changed. Nothing was queued. Refresh the preview and review each server key.');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { readdir, readFile, stat } from '../backends/files.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { DriftPlan, DriftPlanFile } from '$lib/drift-plan.js';
|
||||
import { join, posix, relative, sep } from 'node:path';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { readFile, stat } from '../backends/files.js';
|
||||
|
||||
export async function sha256(path: string): Promise<string> {
|
||||
const buf = await readFile(path);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getBaselineForScope, getVersion, listPromotedOverlaysForScope } from '.
|
||||
import { sha256Buffer } from './files.js';
|
||||
|
||||
export interface LaunchRecipe {
|
||||
native?: { hostId: string; libraryId: string; image?: string; bindAddress?: string; publicAddress?: string; publicPort?: number };
|
||||
format: 1;
|
||||
game: ZombieGame;
|
||||
mapSlug: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { open, stat } from 'node:fs/promises';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { rootOfServer, gameOfServer } from '../config.js';
|
||||
import { nativeTail } from '../backends/files.js';
|
||||
|
||||
// Whitelisted log files exposed to the UI, mapped to their path relative
|
||||
// to each server's root directory. Adding a new supported log = one entry
|
||||
@@ -63,6 +64,8 @@ export async function tailLog(
|
||||
path: string,
|
||||
lines: number = DEFAULT_TAIL_LINES
|
||||
): Promise<TailResult | null> {
|
||||
const remote = await nativeTail(path, lines);
|
||||
if (remote !== undefined) return remote;
|
||||
let st;
|
||||
try {
|
||||
st = await stat(path);
|
||||
|
||||
@@ -3,6 +3,8 @@ import { existsSync } from 'node:fs';
|
||||
import { config, destOf, serverDir, rootOfServer } from '../config.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { parseServerName } from './map-aliases.js';
|
||||
import { nativeInstance } from '../backends/native.js';
|
||||
import { stat as managedStat } from '../backends/files.js';
|
||||
|
||||
/**
|
||||
* All server folders under the game roots. Used by seedServerRegistry to backfill
|
||||
@@ -46,6 +48,10 @@ export async function ensureDest(
|
||||
create: boolean
|
||||
): Promise<{ ok: true; path: string } | { ok: false; reason: string }> {
|
||||
const dest = destOf(server);
|
||||
if (nativeInstance(server)) {
|
||||
try { return (await managedStat(dest)).isDirectory() ? { ok: true, path: dest } : { ok: false, reason: 'Agent script directory is unavailable' }; }
|
||||
catch (error) { return { ok: false, reason: (error as Error).message }; }
|
||||
}
|
||||
if (existsSync(dest)) {
|
||||
try {
|
||||
const s = await stat(dest);
|
||||
@@ -63,6 +69,10 @@ export async function ensureDest(
|
||||
// -- server registry --
|
||||
|
||||
export interface ServerRow {
|
||||
native_instance_id?: string | null;
|
||||
host_id?: string | null;
|
||||
instance_generation?: number | null;
|
||||
game_library_id?: string | null;
|
||||
name: string;
|
||||
komodo_type: 'deployment' | 'stack' | 'container';
|
||||
komodo_name: string;
|
||||
@@ -146,11 +156,13 @@ export async function seedServerRegistry(): Promise<number> {
|
||||
}
|
||||
|
||||
export function getServer(name: string): ServerRow | undefined {
|
||||
return db().prepare('SELECT * FROM servers WHERE name = ?').get(name) as ServerRow | undefined;
|
||||
return db().prepare(`SELECT s.*, n.id AS native_instance_id, n.host_id, n.generation AS instance_generation, n.library_id AS game_library_id
|
||||
FROM servers s LEFT JOIN native_instances n ON n.server=s.name AND n.retired_at IS NULL WHERE s.name=?`).get(name) as ServerRow | undefined;
|
||||
}
|
||||
|
||||
export function listServers(): ServerRow[] {
|
||||
return db().prepare('SELECT * FROM servers WHERE destroyed_at IS NULL ORDER BY name').all() as ServerRow[];
|
||||
return db().prepare(`SELECT s.*, n.id AS native_instance_id, n.host_id, n.generation AS instance_generation, n.library_id AS game_library_id
|
||||
FROM servers s LEFT JOIN native_instances n ON n.server=s.name AND n.retired_at IS NULL WHERE s.destroyed_at IS NULL ORDER BY s.name`).all() as ServerRow[];
|
||||
}
|
||||
|
||||
/** Beta ownership lasts until explicit closure, regardless of test outcome or stage. */
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getLibraryFile } from './index.js';
|
||||
import { assertRegularPath, parseByteRange } from './storage.js';
|
||||
|
||||
export function gameFileError(cause: unknown): Response {
|
||||
const status = typeof cause === 'object' && cause !== null && 'status' in cause && typeof cause.status === 'number' ? cause.status : 400;
|
||||
return json({ error: cause instanceof Error ? cause.message : 'Game file request failed.' }, { status });
|
||||
}
|
||||
export async function readGameJson(request: Request, maxBytes = 64 * 1024): Promise<Record<string, unknown>> {
|
||||
if (!request.body) throw new Error('A JSON request body is required.');
|
||||
const reader = request.body.getReader();
|
||||
const chunks: Uint8Array[] = []; let bytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const next = await reader.read(); if (next.done) break;
|
||||
bytes += next.value.byteLength;
|
||||
if (bytes > maxBytes) { await reader.cancel(); throw new Error('Request body exceeds its size limit.'); }
|
||||
chunks.push(next.value);
|
||||
}
|
||||
} finally { reader.releaseLock(); }
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Expected a JSON object.');
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function serveGameFile(request: Request, versionId: string, requestedPath: string): Promise<Response> {
|
||||
const file = getLibraryFile(versionId, requestedPath);
|
||||
if (!file || file.state !== 'ready') return json({ error: 'File is not in a published manifest.' }, { status: 404 });
|
||||
const path = await assertRegularPath(file.storageLocation, file.path);
|
||||
if ((await stat(path)).size !== file.size) return json({ error: 'Stored file failed size verification.' }, { status: 503 });
|
||||
const etag = `"${file.sha256}"`;
|
||||
const headers = new Headers({ 'Content-Type': 'application/octet-stream', 'Accept-Ranges': 'bytes', ETag: etag, 'Cache-Control': 'private, no-cache', 'X-Content-SHA256': file.sha256 });
|
||||
if (request.headers.get('if-none-match') === etag) return new Response(null, { status: 304, headers });
|
||||
let range;
|
||||
try { range = parseByteRange(request.headers.has('if-range') && request.headers.get('if-range') !== etag ? null : request.headers.get('range'), file.size); }
|
||||
catch { headers.set('Content-Range', `bytes */${file.size}`); return new Response(null, { status: 416, headers }); }
|
||||
headers.set('Content-Length', String(range ? range.end - range.start + 1 : file.size));
|
||||
if (range) headers.set('Content-Range', `bytes ${range.start}-${range.end}/${file.size}`);
|
||||
const body = request.method === 'HEAD' ? null : Readable.toWeb(createReadStream(path, range ?? undefined)) as ReadableStream<Uint8Array>;
|
||||
return new Response(body, { status: range ? 206 : 200, headers });
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, rename, stat } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { db } from '../db/index.js';
|
||||
import { config } from '../config.js';
|
||||
import { getHost, getHostOperation, listHosts, queueHostOperation } from '../hosts/index.js';
|
||||
import { ensureGameFileSchema } from './schema.js';
|
||||
import { manifestChecksum, receiveGameFile, validateGamePath, validateManifest, verifyGameFile } from './storage.js';
|
||||
import type { GameFileEntry, GameLibrary, GameLibraryGame, GameLibraryVersion, HostLibraryCache, LibraryCacheState } from '../../game-files.js';
|
||||
|
||||
function database() { const handle = db(); ensureGameFileSchema(handle); return handle; }
|
||||
export const gameFilesRoot = () => resolve(process.env.GAME_FILES_ROOT ?? join(dirname(config.dbPath), 'game-files'));
|
||||
const stagingRoot = (id: string) => join(gameFilesRoot(), '.imports', id);
|
||||
type VersionRow = { id: string; library_id: string; game: GameLibraryGame; version: string; description: string; dlc: string; state: GameLibraryVersion['state']; size_bytes: number; manifest: string; manifest_sha256: string | null; created_at: string; published_at: string | null; storage_location: string; source_host_id: string; source_volume: string; error: string | null; operation_id: string | null };
|
||||
type CacheRow = { host_id: string; version_id: string; status: LibraryCacheState; bytes_downloaded: number; bytes_total: number; manifest_sha256: string | null; error: string | null; updated_at: string; operation_id: string | null };
|
||||
const cacheFromRow = (row: CacheRow): HostLibraryCache => ({ hostId: row.host_id, libraryVersionId: row.version_id, status: row.status, bytesDownloaded: row.bytes_downloaded, bytesTotal: row.bytes_total, manifestSha256: row.manifest_sha256, error: row.error, updatedAt: row.updated_at, operationId: row.operation_id });
|
||||
function versionFromRow(row: VersionRow): GameLibraryVersion {
|
||||
const files = JSON.parse(row.manifest) as GameFileEntry[];
|
||||
return { id: row.id, libraryId: row.library_id, game: row.game, version: row.version,
|
||||
description: row.description, dlc: JSON.parse(row.dlc), state: row.state, sizeBytes: row.size_bytes,
|
||||
files, fileCount: files.length, manifestSha256: row.manifest_sha256, createdAt: row.created_at,
|
||||
publishedAt: row.published_at, storageLocation: row.storage_location, sourceHostId: row.source_host_id,
|
||||
sourceVolume: row.source_volume, error: row.error, operationId: row.operation_id,
|
||||
caches: (database().prepare('SELECT * FROM host_library_caches WHERE version_id = ?').all(row.id) as CacheRow[]).map(cacheFromRow) };
|
||||
}
|
||||
export function getLibraryVersion(id: string): GameLibraryVersion | null {
|
||||
const row = database().prepare('SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id WHERE v.id=?').get(id) as VersionRow | undefined;
|
||||
return row ? versionFromRow(row) : null;
|
||||
}
|
||||
export function getLibraryFile(versionId: string, path: string) {
|
||||
validateGamePath(path);
|
||||
return database().prepare(`SELECT f.path,f.size,f.sha256,v.state,v.source_host_id AS sourceHostId,v.storage_location AS storageLocation
|
||||
FROM game_file_entries f JOIN game_file_versions v ON v.id=f.version_id WHERE f.version_id=? AND f.path=?`)
|
||||
.get(versionId, path) as (GameFileEntry & { state: GameLibraryVersion['state']; sourceHostId: string; storageLocation: string }) | undefined ?? null;
|
||||
}
|
||||
export function newestReadyLibrary(game: string): GameLibraryVersion | null {
|
||||
const row = database().prepare("SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id WHERE l.game=? AND v.state='ready' ORDER BY v.created_at DESC, v.rowid DESC LIMIT 1").get(game) as VersionRow | undefined;
|
||||
return row ? versionFromRow(row) : null;
|
||||
}
|
||||
export function listLibraries(): GameLibrary[] {
|
||||
reconcileLibraryOperations();
|
||||
return (database().prepare('SELECT * FROM game_file_libraries ORDER BY name').all() as Array<{ id: string; name: string; game: GameLibraryGame; description: string; created_at: string }>).map(row => ({
|
||||
id: row.id, name: row.name, game: row.game, description: row.description, createdAt: row.created_at,
|
||||
versions: (database().prepare('SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id WHERE l.id=? ORDER BY v.created_at DESC, v.rowid DESC').all(row.id) as VersionRow[]).map(versionFromRow)
|
||||
}));
|
||||
}
|
||||
export function getHostLibraryCache(hostId: string, versionId: string): HostLibraryCache | null {
|
||||
const row = database().prepare('SELECT * FROM host_library_caches WHERE host_id=? AND version_id=?').get(hostId, versionId) as CacheRow | undefined;
|
||||
return row ? cacheFromRow(row) : null;
|
||||
}
|
||||
export function requireReadyLibrary(hostId: string, versionId: string): GameLibraryVersion {
|
||||
const version = getLibraryVersion(versionId);
|
||||
if (!version || version.state !== 'ready') throw new Error('Select a published game-file version.');
|
||||
const cache = getHostLibraryCache(hostId, versionId);
|
||||
if (!cache || !['ready', 'outdated'].includes(cache.status) || cache.manifestSha256 !== version.manifestSha256) throw new Error('The selected host must finish downloading and verifying this game-file version.');
|
||||
return version;
|
||||
}
|
||||
|
||||
function textField(input: unknown, label: string, max: number, required = true): string {
|
||||
if (typeof input !== 'string' || input.length > max || (required && !input.trim())) throw new Error(`Provide a valid ${label}.`);
|
||||
return input.trim();
|
||||
}
|
||||
export function createLibraryImport(input: Record<string, unknown>): GameLibraryVersion {
|
||||
const game = input.game;
|
||||
if (game !== 't5' && game !== 't6') throw new Error('Choose T5 or T6 game files.');
|
||||
const hostId = textField(input.hostId, 'source host', 128);
|
||||
const host = getHost(hostId);
|
||||
if (!host || !host.connected || !host.credentialActive || host.mode === 'disabled') throw new Error('Choose an online, enrolled source host.');
|
||||
const name = textField(input.name, 'library name', 160);
|
||||
const description = textField(input.description ?? '', 'description', 4000, false);
|
||||
const version = textField(input.version, 'version label', 120);
|
||||
const dlcInput = input.dlc ?? [];
|
||||
if (!Array.isArray(dlcInput) || dlcInput.length > 64) throw new Error('DLC must be a list of content names.');
|
||||
const dlc = dlcInput.map(value => textField(value, 'DLC name', 120));
|
||||
const sourceVolume = game === 't5' ? 't5_game_files' : 't6_game_files';
|
||||
const handle = database();
|
||||
let library = handle.prepare('SELECT id,game FROM game_file_libraries WHERE name=?').get(name) as { id: string; game: string } | undefined;
|
||||
if (library && library.game !== game) throw new Error('This library name is already used for another game.');
|
||||
const id = randomUUID(); const now = new Date().toISOString();
|
||||
handle.transaction(() => {
|
||||
if (!library) {
|
||||
library = { id: randomUUID(), game };
|
||||
handle.prepare('INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES(?,?,?,?,?)').run(library.id, name, game, description, now);
|
||||
}
|
||||
if (handle.prepare('SELECT 1 FROM game_file_versions WHERE library_id=? AND version=?').get(library.id, version)) throw new Error('This version label already exists. Choose a new version.');
|
||||
handle.prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,created_at,storage_location,source_host_id,source_volume)
|
||||
VALUES(?,?,?,?,?,'importing',?,?,?,?)`).run(id, library.id, version, description, JSON.stringify(dlc), now, join(gameFilesRoot(), game, id), hostId, sourceVolume);
|
||||
})();
|
||||
try {
|
||||
const operation = queueHostOperation(hostId, 'library.import', {
|
||||
importId: id, game, sourceVolume, manifestUrl: `/api/agent/imports/${id}`,
|
||||
uploadBaseUrl: `/api/agent/imports/${id}/files`, completeUrl: `/api/agent/imports/${id}/complete`
|
||||
}, { idempotencyKey: `library-import:${id}` });
|
||||
handle.prepare('UPDATE game_file_versions SET operation_id=? WHERE id=?').run(operation.id, id);
|
||||
} catch (cause) {
|
||||
handle.prepare("UPDATE game_file_versions SET state='failed',error=? WHERE id=?").run((cause as Error).message, id);
|
||||
throw cause;
|
||||
}
|
||||
return getLibraryVersion(id)!;
|
||||
}
|
||||
|
||||
export function requireImportOwner(hostId: string, id: string): GameLibraryVersion {
|
||||
const version = getLibraryVersion(id);
|
||||
if (!version || version.sourceHostId !== hostId) throw new Error('Import not found for this host.');
|
||||
return version;
|
||||
}
|
||||
export function setImportManifest(hostId: string, id: string, input: unknown): GameLibraryVersion {
|
||||
const version = requireImportOwner(hostId, id);
|
||||
const files = validateManifest(input); const checksum = manifestChecksum(files);
|
||||
if (version.manifestSha256) {
|
||||
if (version.manifestSha256 !== checksum) throw new Error('An import manifest cannot change. Start a new version.');
|
||||
return version;
|
||||
}
|
||||
if (version.state !== 'importing') throw new Error('This import is not writable.');
|
||||
database().transaction(() => {
|
||||
database().prepare('UPDATE game_file_versions SET manifest=?,manifest_sha256=?,size_bytes=? WHERE id=? AND state=\'importing\'').run(JSON.stringify(files), checksum, files.reduce((sum, file) => sum + file.size, 0), id);
|
||||
const insert = database().prepare('INSERT INTO game_file_entries(version_id,path,size,sha256) VALUES(?,?,?,?)');
|
||||
for (const file of files) insert.run(id, file.path, file.size, file.sha256);
|
||||
})();
|
||||
return getLibraryVersion(id)!;
|
||||
}
|
||||
export async function uploadImportFile(hostId: string, id: string, path: string, body: ReadableStream<Uint8Array> | null): Promise<void> {
|
||||
const file = getLibraryFile(id, path);
|
||||
if (!file || file.sourceHostId !== hostId) throw new Error('File is not listed in a manifest belonging to this host.');
|
||||
if (file.state !== 'importing' || publishing.has(id)) throw new Error('This import is not writable.');
|
||||
await receiveGameFile(stagingRoot(id), file, body);
|
||||
}
|
||||
|
||||
const publishing = new Map<string, Promise<GameLibraryVersion>>();
|
||||
export async function publishImport(hostId: string, id: string): Promise<GameLibraryVersion> {
|
||||
const version = requireImportOwner(hostId, id);
|
||||
if (version.state === 'ready') return version;
|
||||
if (version.state !== 'importing' || !version.files.length || !version.manifestSha256) throw new Error('Import needs a complete manifest before publication.');
|
||||
const existing = publishing.get(id); if (existing) return existing;
|
||||
const task = (async () => {
|
||||
let root = stagingRoot(id);
|
||||
// A restart between the atomic directory rename and metadata commit is recoverable.
|
||||
try { if ((await stat(version.storageLocation)).isDirectory()) root = version.storageLocation; }
|
||||
catch (cause) { if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') throw cause; }
|
||||
for (const file of version.files) await verifyGameFile(root, file);
|
||||
if (root !== version.storageLocation) {
|
||||
await mkdir(dirname(version.storageLocation), { recursive: true });
|
||||
await rename(root, version.storageLocation);
|
||||
}
|
||||
database().prepare("UPDATE game_file_versions SET state='ready',published_at=?,error=NULL WHERE id=? AND state='importing'").run(new Date().toISOString(), id);
|
||||
return getLibraryVersion(id)!;
|
||||
})();
|
||||
publishing.set(id, task);
|
||||
try { return await task; } finally { publishing.delete(id); }
|
||||
}
|
||||
|
||||
/** Large libraries can take minutes to verify. Keep HTTP requests bounded while the source host polls. */
|
||||
export function importPublicationStatus(hostId: string, id: string) {
|
||||
const version = requireImportOwner(hostId, id);
|
||||
return {
|
||||
status: version.state === 'ready' ? 'published' as const : version.state === 'failed' ? 'failed' as const : publishing.has(id) ? 'verifying' as const : 'pending' as const,
|
||||
version: { id: version.id, game: version.game, sizeBytes: version.sizeBytes, manifestSha256: version.manifestSha256 },
|
||||
error: version.error
|
||||
};
|
||||
}
|
||||
export function requestImportPublication(hostId: string, id: string) {
|
||||
const version = requireImportOwner(hostId, id);
|
||||
if (version.state === 'importing' && !publishing.has(id)) {
|
||||
if (!version.files.length || !version.manifestSha256) throw new Error('Import requires a manifest before publication.');
|
||||
void publishImport(hostId, id).catch(cause => {
|
||||
database().prepare("UPDATE game_file_versions SET state='failed',error=? WHERE id=? AND state='importing'")
|
||||
.run((cause instanceof Error ? cause.message : 'Library publication failed.').slice(0, 2000), id);
|
||||
});
|
||||
}
|
||||
return importPublicationStatus(hostId, id);
|
||||
}
|
||||
|
||||
export function updateHostLibraryCaches(hostId: string, input: unknown, options: { snapshot?: boolean } = {}): void {
|
||||
if (!Array.isArray(input) || input.length > 2000) throw new Error('Invalid host library report.');
|
||||
const handle = database();
|
||||
const now = new Date().toISOString();
|
||||
handle.transaction(() => {
|
||||
const reported = new Set<string>();
|
||||
for (const raw of input) {
|
||||
if (!raw || typeof raw !== 'object') throw new Error('Invalid library cache report.');
|
||||
const id = raw.libraryVersionId ?? raw.id;
|
||||
if (typeof id !== 'string') throw new Error('Invalid library version ID.');
|
||||
if (reported.has(id)) throw new Error('Duplicate version in host cache report.');
|
||||
reported.add(id);
|
||||
const version = getLibraryVersion(id); if (!version || version.state !== 'ready') continue;
|
||||
let status = raw.status ?? raw.state;
|
||||
if (!['missing', 'downloading', 'verifying', 'ready', 'failed', 'outdated'].includes(status)) throw new Error('Invalid library cache state.');
|
||||
const checksum = typeof raw.manifestSha256 === 'string' ? raw.manifestSha256 : null;
|
||||
let error = typeof raw.error === 'string' ? raw.error.slice(0, 2000) : null;
|
||||
if (['ready', 'outdated'].includes(status) && checksum !== version.manifestSha256) { status = 'failed'; error = 'Host manifest checksum does not match the published version.'; }
|
||||
const bytes = raw.bytesDownloaded ?? (['ready', 'outdated'].includes(status) ? version.sizeBytes : 0);
|
||||
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > version.sizeBytes) throw new Error('Invalid library download progress.');
|
||||
if (['ready', 'outdated'].includes(status) && bytes !== version.sizeBytes) { status = 'failed'; error = 'Host reported an incomplete library as verified.'; }
|
||||
handle.prepare(`INSERT INTO host_library_caches(host_id,version_id,status,bytes_downloaded,bytes_total,manifest_sha256,error,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(host_id,version_id) DO UPDATE SET status=excluded.status,
|
||||
bytes_downloaded=excluded.bytes_downloaded,bytes_total=excluded.bytes_total,manifest_sha256=excluded.manifest_sha256,error=excluded.error,updated_at=excluded.updated_at`)
|
||||
.run(hostId, id, status, bytes, version.sizeBytes, checksum, error, now);
|
||||
}
|
||||
if (options.snapshot) {
|
||||
const cached = handle.prepare('SELECT version_id FROM host_library_caches WHERE host_id=?').all(hostId) as { version_id: string }[];
|
||||
for (const cache of cached) if (!reported.has(cache.version_id)) {
|
||||
handle.prepare("UPDATE host_library_caches SET status='missing',bytes_downloaded=0,manifest_sha256=NULL,error=NULL,updated_at=? WHERE host_id=? AND version_id=?")
|
||||
.run(now, hostId, cache.version_id);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
export function queueLibraryDownload(hostId: string, versionId: string) {
|
||||
const version = getLibraryVersion(versionId);
|
||||
if (!version || version.state !== 'ready') throw new Error('Choose a published library version.');
|
||||
const host = getHost(hostId);
|
||||
if (!host || !host.connected || !host.credentialActive || host.mode === 'disabled') throw new Error('Choose an online, enrolled host.');
|
||||
if (Number(host.inventory.storageAvailable ?? 0) < version.sizeBytes + host.minimumFreeBytes) throw new Error('This host has insufficient storage for this library and its minimum free-space reserve.');
|
||||
const prior = getHostLibraryCache(hostId, versionId);
|
||||
if (prior?.operationId) {
|
||||
const operation = getHostOperation(prior.operationId);
|
||||
if (operation && ['pending', 'accepted', 'running', 'unknown'].includes(operation.state)) return operation;
|
||||
}
|
||||
const operation = queueHostOperation(hostId, 'library.download', {
|
||||
libraryVersionId: versionId, game: version.game, manifestUrl: `/api/agent/libraries/${versionId}/manifest`,
|
||||
filesBaseUrl: `/api/agent/libraries/${versionId}/files`, statusUrl: `/api/agent/libraries/${versionId}/status`,
|
||||
manifestSha256: version.manifestSha256, sizeBytes: version.sizeBytes
|
||||
});
|
||||
updateHostLibraryCaches(hostId, [{ libraryVersionId: versionId, status: 'downloading', bytesDownloaded: 0 }]);
|
||||
database().prepare('UPDATE host_library_caches SET operation_id=? WHERE host_id=? AND version_id=?').run(operation.id, hostId, versionId);
|
||||
return operation;
|
||||
}
|
||||
|
||||
export function reconcileLibraryOperations(): void {
|
||||
const handle = database();
|
||||
for (const row of handle.prepare("SELECT id,operation_id FROM game_file_versions WHERE state='importing' AND operation_id IS NOT NULL").all() as { id: string; operation_id: string }[]) {
|
||||
const operation = getHostOperation(row.operation_id);
|
||||
if (operation?.state === 'failed') handle.prepare("UPDATE game_file_versions SET state='failed',error=? WHERE id=? AND state='importing'").run(operation.error ?? 'Host import failed.', row.id);
|
||||
}
|
||||
for (const cache of handle.prepare("SELECT * FROM host_library_caches WHERE status IN ('downloading','verifying') AND operation_id IS NOT NULL").all() as CacheRow[]) {
|
||||
const operation = getHostOperation(cache.operation_id!);
|
||||
if (operation?.state === 'failed') handle.prepare("UPDATE host_library_caches SET status='failed',error=?,updated_at=? WHERE host_id=? AND version_id=?").run(operation.error ?? 'Host download failed.', new Date().toISOString(), cache.host_id, cache.version_id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Lightweight list used in launch forms; large manifests stay on the detail endpoint. */
|
||||
export function libraryOverview() {
|
||||
const hosts = listHosts();
|
||||
return { libraries: listLibraries().map(library => ({ ...library, versions: library.versions.map(({ files: _files, ...version }) => ({
|
||||
...version, caches: hosts.map(host => {
|
||||
const cached = getHostLibraryCache(host.id, version.id);
|
||||
const latest = library.versions.find(item => item.state === 'ready');
|
||||
if (cached) return { ...cached, status: cached.status === 'ready' && latest?.id !== version.id ? 'outdated' as const : cached.status };
|
||||
return {
|
||||
hostId: host.id, libraryVersionId: version.id, status: 'missing' as const, bytesDownloaded: 0,
|
||||
bytesTotal: version.sizeBytes, manifestSha256: null, error: null, updatedAt: version.createdAt, operationId: null
|
||||
};
|
||||
})
|
||||
})) })), hosts };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
const initialized = new WeakSet<Database.Database>();
|
||||
export function ensureGameFileSchema(handle: Database.Database): void {
|
||||
if (initialized.has(handle)) return;
|
||||
handle.exec(`
|
||||
CREATE TABLE IF NOT EXISTS game_file_libraries (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
||||
game TEXT NOT NULL CHECK(game IN ('t5','t6')), description TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS game_file_versions (
|
||||
id TEXT PRIMARY KEY, library_id TEXT NOT NULL REFERENCES game_file_libraries(id),
|
||||
version TEXT NOT NULL, description TEXT NOT NULL, dlc TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK(state IN ('importing','ready','failed')),
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0, manifest TEXT NOT NULL DEFAULT '[]',
|
||||
manifest_sha256 TEXT, created_at TEXT NOT NULL, published_at TEXT,
|
||||
storage_location TEXT NOT NULL, source_host_id TEXT NOT NULL, source_volume TEXT NOT NULL,
|
||||
error TEXT, operation_id TEXT, UNIQUE(library_id, version)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS host_library_caches (
|
||||
host_id TEXT NOT NULL, version_id TEXT NOT NULL REFERENCES game_file_versions(id),
|
||||
status TEXT NOT NULL CHECK(status IN ('missing','downloading','verifying','ready','failed','outdated')),
|
||||
bytes_downloaded INTEGER NOT NULL DEFAULT 0, bytes_total INTEGER NOT NULL DEFAULT 0,
|
||||
manifest_sha256 TEXT, error TEXT, updated_at TEXT NOT NULL, operation_id TEXT,
|
||||
PRIMARY KEY(host_id, version_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS game_file_entries (
|
||||
version_id TEXT NOT NULL REFERENCES game_file_versions(id), path TEXT NOT NULL,
|
||||
size INTEGER NOT NULL, sha256 TEXT NOT NULL, PRIMARY KEY(version_id,path)
|
||||
);
|
||||
INSERT OR IGNORE INTO game_file_entries(version_id,path,size,sha256)
|
||||
SELECT v.id,json_extract(f.value,'$.path'),json_extract(f.value,'$.size'),json_extract(f.value,'$.sha256')
|
||||
FROM game_file_versions v,json_each(v.manifest) f;
|
||||
CREATE TRIGGER IF NOT EXISTS game_file_versions_immutable BEFORE UPDATE ON game_file_versions
|
||||
WHEN OLD.state = 'ready' BEGIN SELECT RAISE(ABORT, 'Published game-file versions are immutable'); END;
|
||||
`);
|
||||
initialized.add(handle);
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import type { Host } from '../../hosts.js';
|
||||
import { authenticateCredential } from './index.js';
|
||||
|
||||
export function requireAgentTransport(request: Request): void {
|
||||
if (new URL(request.url).protocol !== 'https:' && process.env.EC_AGENT_ALLOW_HTTP !== 'true') error(426, 'Host agents require HTTPS.');
|
||||
}
|
||||
export function authenticateAgent(request: Request): Host {
|
||||
requireAgentTransport(request);
|
||||
const authorization = request.headers.get('authorization');
|
||||
const token = authorization?.match(/^Bearer ([A-Za-z0-9_-]+)$/i)?.[1];
|
||||
const host = token ? authenticateCredential(token) : null;
|
||||
if (!host) error(401, 'A valid host credential is required.');
|
||||
return host;
|
||||
}
|
||||
export function requireManagementOrigin(request: Request): void {
|
||||
const origin = request.headers.get('origin');
|
||||
if ((origin && origin !== new URL(request.url).origin) || request.headers.get('sec-fetch-site') === 'cross-site') error(403, 'Cross-origin host management requests are not allowed.');
|
||||
// Agent credentials only authorize agent endpoints, never host management.
|
||||
if (request.headers.get('authorization')?.match(/^Bearer ec_(?:host|enroll)_/i)) error(403, 'Agent credentials cannot manage the controller.');
|
||||
}
|
||||
export async function objectBody(request: Request, maxBytes = 1024 * 1024): Promise<Record<string, unknown>> {
|
||||
if (Number(request.headers.get('content-length')) > maxBytes) error(413, 'Request is too large.');
|
||||
const reader = request.body?.getReader();
|
||||
if (!reader) error(400, 'Request body must be valid JSON.');
|
||||
const chunks: Uint8Array[] = []; let received = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
received += value.byteLength;
|
||||
if (received > maxBytes) { void reader.cancel().catch(() => {}); error(413, 'Request is too large.'); }
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally { reader.releaseLock(); }
|
||||
const raw = Buffer.concat(chunks, received).toString('utf8');
|
||||
let body: unknown;
|
||||
try { body = JSON.parse(raw); } catch { error(400, 'Request body must be valid JSON.'); }
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) error(400, 'Request body must be an object.');
|
||||
return body as Record<string, unknown>;
|
||||
}
|
||||
export function hostApiError(cause: unknown): Response {
|
||||
if (cause && typeof cause === 'object' && 'status' in cause && 'body' in cause) throw cause;
|
||||
return json({ error: cause instanceof Error ? cause.message : 'Host request failed.' }, { status: 400 });
|
||||
}
|
||||
export function installerCommand(controller: string, token: string): string {
|
||||
const origin = new URL(controller).origin;
|
||||
const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
|
||||
return `curl --fail --silent --show-error --proto '=https' ${quote(origin + '/api/agent/install.sh')} | sudo bash -s -- --controller ${quote(origin)} --token ${quote(token)}`;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { isIP } from 'node:net';
|
||||
import { db } from '../db/index.js';
|
||||
import { ensureHostSchema } from './schema.js';
|
||||
import type { Host, HostInventory, HostMode, HostOperation, HostOperationState } from '../../hosts.js';
|
||||
|
||||
export const AGENT_PROTOCOL_VERSION = 1;
|
||||
export const HEARTBEAT_MAX_AGE_MS = 90_000;
|
||||
const now = () => new Date().toISOString();
|
||||
const digest = (token: string) => createHash('sha256').update(token).digest('hex');
|
||||
function database() { const handle = db(); ensureHostSchema(handle); return handle; }
|
||||
type HostRow = { id: string; name: string; hostname: string; mode: HostMode; port_min: number; port_max: number; bind_address: string; public_address: string; minimum_free_bytes: number; created_at: string; enrolled_at: string | null; last_heartbeat: string | null; inventory: string; credential_active: number };
|
||||
const hostSql = `SELECT h.*, EXISTS(SELECT 1 FROM ec_host_credentials c WHERE c.host_id = h.id AND c.revoked_at IS NULL) AS credential_active FROM ec_hosts h`;
|
||||
function hostFromRow(row: HostRow): Host {
|
||||
const inventory: HostInventory = JSON.parse(row.inventory);
|
||||
const connected = !!row.credential_active && !!row.last_heartbeat && Date.now() - Date.parse(row.last_heartbeat) <= HEARTBEAT_MAX_AGE_MS;
|
||||
const reasons: string[] = [];
|
||||
if (!row.enrolled_at) reasons.push('Agent has not enrolled.');
|
||||
else if (!row.credential_active) reasons.push('Agent credential has been revoked.');
|
||||
if (!connected) reasons.push('Agent heartbeat is unavailable or stale.');
|
||||
if (inventory.protocolVersion !== AGENT_PROTOCOL_VERSION) reasons.push('A compatible agent protocol is required.');
|
||||
if (!inventory.dockerRunning) reasons.push('Docker is not ready.');
|
||||
if (!inventory.directoriesWritable) reasons.push('Managed directories are not writable.');
|
||||
if (typeof inventory.storageAvailable !== 'number' || inventory.storageAvailable < row.minimum_free_bytes) reasons.push('Insufficient available game storage.');
|
||||
const infrastructureReady = reasons.length === 0;
|
||||
if (!inventory.runtimeImages?.length) reasons.push('Prepare a Plutainer runtime image.');
|
||||
if (!inventory.libraries?.some(library => library.state === 'ready')) reasons.push('Download and verify a game-file library.');
|
||||
if (row.mode === 'draining') reasons.push('Host is draining; new deployments are blocked.');
|
||||
if (row.mode === 'disabled') reasons.push('Host is disabled.');
|
||||
return { id: row.id, name: row.name, hostname: row.hostname, mode: row.mode, portMin: row.port_min, portMax: row.port_max,
|
||||
bindAddress: row.bind_address, publicAddress: row.public_address, minimumFreeBytes: row.minimum_free_bytes,
|
||||
createdAt: row.created_at, lastHeartbeat: row.last_heartbeat, connected, connectionState: connected ? 'online' : 'offline',
|
||||
readiness: row.mode === 'draining' ? 'draining' : row.mode === 'disabled' ? 'disabled' : !row.enrolled_at || (infrastructureReady && reasons.length) ? 'preparing' : reasons.length ? 'degraded' : 'ready',
|
||||
readinessReasons: reasons, inventory, enrolled: !!row.enrolled_at, credentialActive: !!row.credential_active };
|
||||
}
|
||||
export function listHosts(): Host[] { return (database().prepare(hostSql + ' ORDER BY h.name, h.id').all() as HostRow[]).map(hostFromRow); }
|
||||
export function getHost(id: string): Host | null {
|
||||
const row = database().prepare(hostSql + ' WHERE h.id = ?').get(id) as HostRow | undefined;
|
||||
return row ? hostFromRow(row) : null;
|
||||
}
|
||||
function requireHost(id: string): Host { const host = getHost(id); if (!host) throw new Error('Host was not found.'); return host; }
|
||||
function textField(value: unknown, label: string, max = 200): string {
|
||||
if (typeof value !== 'string' || !value.trim() || value.length > max || /[\x00-\x1f]/.test(value)) throw new Error(`${label} must be a nonempty string up to ${max} characters.`);
|
||||
return value.trim();
|
||||
}
|
||||
function port(value: unknown): number { if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535) throw new Error('Ports must be whole numbers between 1 and 65535.'); return value; }
|
||||
export function createHost(input: Record<string, unknown>): Host {
|
||||
const id = randomUUID(); const name = textField(input.name, 'Host name');
|
||||
const min = port(input.portMin ?? 28960); const max = port(input.portMax ?? 28980);
|
||||
if (max < min) throw new Error('The last game port must not precede the first.');
|
||||
const bind = input.bindAddress === undefined ? '0.0.0.0' : textField(input.bindAddress, 'Bind address');
|
||||
if (!isIP(bind)) throw new Error('Bind address must be an IPv4 or IPv6 address.');
|
||||
database().prepare('INSERT INTO ec_hosts(id,name,port_min,port_max,bind_address,created_at) VALUES(?,?,?,?,?,?)').run(id, name, min, max, bind, now());
|
||||
return requireHost(id);
|
||||
}
|
||||
export function updateHost(id: string, input: Record<string, unknown>): Host {
|
||||
const host = requireHost(id);
|
||||
const name = input.name === undefined ? host.name : textField(input.name, 'Host name');
|
||||
const mode = input.mode ?? host.mode;
|
||||
if (!['active', 'draining', 'disabled'].includes(mode as string)) throw new Error('Host mode must be active, draining, or disabled.');
|
||||
const min = port(input.portMin ?? host.portMin); const max = port(input.portMax ?? host.portMax);
|
||||
if (max < min) throw new Error('The last game port must not precede the first.');
|
||||
const bind = input.bindAddress === undefined ? host.bindAddress : textField(input.bindAddress, 'Bind address');
|
||||
if (!isIP(bind)) throw new Error('Bind address must be an IPv4 or IPv6 address.');
|
||||
const publicAddress = input.publicAddress === undefined ? host.publicAddress : input.publicAddress === '' ? '' : textField(input.publicAddress, 'Public address');
|
||||
const minimum = input.minimumFreeBytes ?? host.minimumFreeBytes;
|
||||
if (typeof minimum !== 'number' || !Number.isSafeInteger(minimum) || minimum < 0) throw new Error('Minimum free storage must be a nonnegative byte count.');
|
||||
database().prepare('UPDATE ec_hosts SET name=?,mode=?,port_min=?,port_max=?,bind_address=?,public_address=?,minimum_free_bytes=? WHERE id=?').run(name, mode, min, max, bind, publicAddress, minimum, id);
|
||||
return requireHost(id);
|
||||
}
|
||||
|
||||
export function createEnrollment(hostId: string, ttlMs = 15 * 60_000): { id: string; hostId: string; token: string; expiresAt: string } {
|
||||
requireHost(hostId);
|
||||
if (!Number.isInteger(ttlMs) || ttlMs <= 0 || ttlMs > 60 * 60_000) throw new Error('Enrollment lifetime must be at most one hour.');
|
||||
const token = 'ec_enroll_' + randomBytes(32).toString('base64url'); const id = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + ttlMs).toISOString();
|
||||
database().transaction(() => {
|
||||
database().prepare('UPDATE ec_host_enrollments SET consumed_at=? WHERE host_id=? AND consumed_at IS NULL').run(now(), hostId);
|
||||
database().prepare('INSERT INTO ec_host_enrollments(id,host_id,token_hash,created_at,expires_at) VALUES(?,?,?,?,?)').run(id, hostId, digest(token), now(), expiresAt);
|
||||
})();
|
||||
return { id, hostId, token, expiresAt };
|
||||
}
|
||||
function issueCredential(hostId: string, credential = 'ec_host_' + randomBytes(32).toString('base64url')): string {
|
||||
database().prepare('INSERT INTO ec_host_credentials(id,host_id,token_hash,created_at) VALUES(?,?,?,?)').run(randomUUID(), hostId, digest(credential), now());
|
||||
return credential;
|
||||
}
|
||||
export function validateInventory(value: unknown): HostInventory {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Host inventory must be an object.');
|
||||
const inventory = value as HostInventory;
|
||||
if (JSON.stringify(inventory).length > 256_000) throw new Error('Host inventory is too large.');
|
||||
for (const key of ['hostname','agentVersion','os','architecture','cpu','dockerVersion']) {
|
||||
if (inventory[key] !== undefined && (typeof inventory[key] !== 'string' || String(inventory[key]).length > 1000)) throw new Error(`Invalid inventory ${key}.`);
|
||||
}
|
||||
for (const key of ['cpuCount','memoryTotal','memoryAvailable','storageTotal','storageAvailable','protocolVersion']) {
|
||||
if (inventory[key] !== undefined && (typeof inventory[key] !== 'number' || !Number.isSafeInteger(inventory[key]) || Number(inventory[key]) < 0)) throw new Error(`Invalid inventory ${key}.`);
|
||||
}
|
||||
for (const key of ['dockerRunning','directoriesWritable']) if (inventory[key] !== undefined && typeof inventory[key] !== 'boolean') throw new Error(`Invalid inventory ${key}.`);
|
||||
if (inventory.runtimeImages !== undefined && (!Array.isArray(inventory.runtimeImages) || inventory.runtimeImages.some(image => typeof image !== 'string'))) throw new Error('Invalid runtime image inventory.');
|
||||
if (inventory.libraries !== undefined && (!Array.isArray(inventory.libraries) || inventory.libraries.length > 2000 || inventory.libraries.some(library =>
|
||||
!library || typeof library !== 'object' || typeof library.id !== 'string' || !library.id || library.id.length > 200 || !['missing','downloading','verifying','ready','failed','outdated'].includes(library.state)))) throw new Error('Invalid game library inventory.');
|
||||
return inventory;
|
||||
}
|
||||
export function enrollHost(token: unknown, rawInventory: unknown): { hostId: string; credential: string; heartbeatSeconds: number } {
|
||||
if (typeof token !== 'string' || token.length > 200) throw new Error('Enrollment token is invalid or expired.');
|
||||
const inventory = validateInventory(rawInventory);
|
||||
return database().transaction(() => {
|
||||
const enrollment = database().prepare('SELECT id,host_id FROM ec_host_enrollments WHERE token_hash=? AND consumed_at IS NULL AND expires_at>?').get(digest(token), now()) as { id: string; host_id: string } | undefined;
|
||||
if (!enrollment) throw new Error('Enrollment token is invalid or expired.');
|
||||
const changed = database().prepare('UPDATE ec_host_enrollments SET consumed_at=? WHERE id=? AND consumed_at IS NULL').run(now(), enrollment.id);
|
||||
if (changed.changes !== 1) throw new Error('Enrollment token has already been consumed.');
|
||||
revokeCredentials(enrollment.host_id);
|
||||
const credential = issueCredential(enrollment.host_id);
|
||||
database().prepare('UPDATE ec_hosts SET enrolled_at=?,last_heartbeat=?,hostname=?,inventory=? WHERE id=?').run(now(), now(), inventory.hostname ?? '', JSON.stringify(inventory), enrollment.host_id);
|
||||
return { hostId: enrollment.host_id, credential, heartbeatSeconds: 15 };
|
||||
})();
|
||||
}
|
||||
export function authenticateCredential(token: string): Host | null {
|
||||
if (!token || token.length > 200) return null;
|
||||
const row = database().prepare('SELECT id,host_id FROM ec_host_credentials WHERE token_hash=? AND revoked_at IS NULL').get(digest(token)) as { id: string; host_id: string } | undefined;
|
||||
if (!row) return null;
|
||||
database().prepare('UPDATE ec_host_credentials SET last_used_at=? WHERE id=?').run(now(), row.id);
|
||||
return getHost(row.host_id);
|
||||
}
|
||||
export function revokeCredentials(hostId: string): void {
|
||||
requireHost(hostId);
|
||||
database().prepare('UPDATE ec_host_credentials SET revoked_at=? WHERE host_id=? AND revoked_at IS NULL').run(now(), hostId);
|
||||
}
|
||||
export function rotateCredential(hostId: string, nextCredential?: string): string {
|
||||
requireHost(hostId);
|
||||
if (nextCredential !== undefined && !/^ec_host_[A-Za-z0-9_-]{43}$/.test(nextCredential)) throw new Error('New credentials must contain 32 random bytes encoded as base64url.');
|
||||
return database().transaction(() => {
|
||||
if (nextCredential) {
|
||||
const existing = database().prepare('SELECT host_id,revoked_at FROM ec_host_credentials WHERE token_hash=?').get(digest(nextCredential)) as { host_id: string; revoked_at: string | null } | undefined;
|
||||
if (existing) {
|
||||
if (existing.host_id !== hostId || existing.revoked_at) throw new Error('This credential cannot be reused.');
|
||||
return nextCredential;
|
||||
}
|
||||
}
|
||||
revokeCredentials(hostId); return issueCredential(hostId, nextCredential);
|
||||
})();
|
||||
}
|
||||
export function heartbeat(hostId: string, value: unknown): Host {
|
||||
requireHost(hostId); const inventory = validateInventory(value);
|
||||
database().prepare('UPDATE ec_hosts SET last_heartbeat=?,hostname=?,inventory=? WHERE id=?').run(now(), inventory.hostname ?? '', JSON.stringify(inventory), hostId);
|
||||
return requireHost(hostId);
|
||||
}
|
||||
export function requireReadyHost(hostId: string, options: { libraryId?: string; requiredBytes?: number; image?: string; port?: number; bindAddress?: string; protocol?: 'udp' | 'tcp' } = {}): Host {
|
||||
const host = requireHost(hostId); const reasons = [...host.readinessReasons];
|
||||
if (options.requiredBytes !== undefined && (host.inventory.storageAvailable ?? 0) < options.requiredBytes + host.minimumFreeBytes) reasons.push('Insufficient storage for this deployment.');
|
||||
if (options.libraryId && !host.inventory.libraries?.some(library => library.id === options.libraryId && library.state === 'ready')) reasons.push('The selected game library is not ready on this host.');
|
||||
if (options.image && !host.inventory.runtimeImages?.includes(options.image)) reasons.push('The selected runtime image is not cached on this host.');
|
||||
if (options.port !== undefined && !hostPortAvailable(hostId, options.port, options.bindAddress, options.protocol)) reasons.push('The selected host port is not available.');
|
||||
if (reasons.length) throw new Error(reasons.join(' '));
|
||||
return host;
|
||||
}
|
||||
|
||||
type OperationRow = { id: string; host_id: string; kind: string; instance_id: string | null; generation: number | null; payload: string; state: HostOperationState; result: string | null; error: string | null; created_at: string; updated_at: string };
|
||||
function operationFromRow(row: OperationRow): HostOperation { return { id: row.id, hostId: row.host_id, kind: row.kind, instanceId: row.instance_id, generation: row.generation, payload: JSON.parse(row.payload), state: row.state, result: row.result === null ? null : JSON.parse(row.result), error: row.error, createdAt: row.created_at, updatedAt: row.updated_at }; }
|
||||
export function getHostOperation(id: string): HostOperation | null { const row = database().prepare('SELECT * FROM ec_host_operations WHERE id=?').get(id) as OperationRow | undefined; return row ? operationFromRow(row) : null; }
|
||||
export function listHostOperations(hostId: string, limit = 100): HostOperation[] { return (database().prepare('SELECT * FROM ec_host_operations WHERE host_id=? ORDER BY created_at DESC,rowid DESC LIMIT ?').all(hostId, Math.min(500, Math.max(1, limit))) as OperationRow[]).map(operationFromRow); }
|
||||
export function queueHostOperation(hostId: string, kind: string, payload: Record<string, unknown>, options: { instanceId?: string; generation?: number; idempotencyKey?: string; id?: string } = {}): HostOperation {
|
||||
const host = requireHost(hostId);
|
||||
if (!/^[a-z][a-z0-9_.-]{0,79}$/.test(kind)) throw new Error('Invalid operation kind.');
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('Operation payload must be an object.');
|
||||
if (options.generation !== undefined && (!Number.isSafeInteger(options.generation) || options.generation < 1)) throw new Error('Instance generation must be a positive integer.');
|
||||
return database().transaction(() => {
|
||||
if (options.idempotencyKey) {
|
||||
const existing = database().prepare('SELECT * FROM ec_host_operations WHERE host_id=? AND idempotency_key=?').get(hostId, options.idempotencyKey) as OperationRow | undefined;
|
||||
if (existing) {
|
||||
if (existing.kind !== kind || existing.instance_id !== (options.instanceId ?? null) || existing.generation !== (options.generation ?? null) || existing.payload !== JSON.stringify(payload)) throw new Error('Operation idempotency key is already associated with a different request.');
|
||||
return operationFromRow(existing);
|
||||
}
|
||||
}
|
||||
if (['instance.create','create'].includes(kind)) requireReadyHost(hostId);
|
||||
if (host.mode === 'disabled') throw new Error('Host is disabled. Enable it before requesting operations.');
|
||||
const id = options.id ?? randomUUID(); const timestamp = now();
|
||||
database().prepare('INSERT INTO ec_host_operations(id,host_id,kind,instance_id,generation,idempotency_key,payload,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)').run(id, hostId, kind, options.instanceId ?? null, options.generation ?? null, options.idempotencyKey ?? null, JSON.stringify(payload), timestamp, timestamp);
|
||||
database().prepare('INSERT INTO ec_host_operation_events(operation_id,state,created_at) VALUES(?,?,?)').run(id, 'pending', timestamp);
|
||||
return getHostOperation(id)!;
|
||||
})();
|
||||
}
|
||||
export function pendingHostOperations(hostId: string): HostOperation[] {
|
||||
const host = requireHost(hostId);
|
||||
if (host.mode === 'disabled' || !host.connected) return [];
|
||||
// Accepted/running/unknown commands keep the same immutable ID. The agent
|
||||
// consults its durable journal before acknowledging or executing a replay.
|
||||
return database().transaction(() => {
|
||||
const rows = database().prepare(`SELECT * FROM ec_host_operations WHERE host_id=? AND state IN ('pending','accepted','running','unknown')
|
||||
AND (? OR state != 'pending' OR kind NOT IN ('instance.create','create'))
|
||||
ORDER BY COALESCE(last_delivered_at,''),created_at,rowid LIMIT 25`).all(hostId, Number(host.readiness === 'ready')) as OperationRow[];
|
||||
const deliveredAt = now();
|
||||
for (const operation of rows) database().prepare('UPDATE ec_host_operations SET last_delivered_at=? WHERE id=?').run(deliveredAt, operation.id);
|
||||
return rows.map(operationFromRow);
|
||||
})();
|
||||
}
|
||||
export function reportHostOperation(hostId: string, id: string, input: { state: unknown; result?: unknown; error?: unknown }): HostOperation {
|
||||
return database().transaction(() => {
|
||||
const operation = getHostOperation(id);
|
||||
if (!operation || operation.hostId !== hostId) throw new Error('Operation was not found for this host.');
|
||||
if (!['accepted','running','completed','failed','unknown'].includes(input.state as string)) throw new Error('Invalid operation state.');
|
||||
if (['completed','failed'].includes(operation.state)) return operation;
|
||||
const state = input.state as HostOperationState;
|
||||
if (operation.state === 'running' && state === 'accepted') return operation;
|
||||
const error = input.error === undefined || input.error === null ? null : String(input.error).slice(0, 4000);
|
||||
const result = input.result === undefined ? null : JSON.stringify(input.result);
|
||||
if (result !== null && Buffer.byteLength(result) > 128 * 1024 * 1024) throw new Error('Operation result exceeds the limit.');
|
||||
database().prepare('UPDATE ec_host_operations SET state=?,result=?,error=?,updated_at=? WHERE id=?').run(state, result, error, now(), id);
|
||||
if (operation.state !== state) database().prepare('INSERT INTO ec_host_operation_events(operation_id,state,created_at,error) VALUES(?,?,?,?)').run(id, state, now(), error);
|
||||
return getHostOperation(id)!;
|
||||
})();
|
||||
}
|
||||
export async function waitHostOperation(id: string, timeoutMs = 30_000): Promise<HostOperation> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (true) {
|
||||
const operation = getHostOperation(id);
|
||||
if (!operation) throw new Error('Host operation was not found.');
|
||||
if (['completed','failed','unknown'].includes(operation.state)) return operation;
|
||||
if (Date.now() >= deadline) throw new Error(`Host operation ${id} is awaiting acknowledgement; reconcile this operation ID before retrying.`);
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
}
|
||||
|
||||
export function hostPortAvailable(hostId: string, candidate: number, bindAddress?: string, protocol: 'udp' | 'tcp' = 'udp', instanceId?: string): boolean {
|
||||
const host = requireHost(hostId); port(candidate); const bind = bindAddress ?? host.bindAddress;
|
||||
if (candidate < host.portMin || candidate > host.portMax) return false;
|
||||
const rows = database().prepare('SELECT instance_id,bind_address FROM ec_host_ports WHERE host_id=? AND protocol=? AND port=?').all(hostId, protocol, candidate) as { instance_id: string; bind_address: string }[];
|
||||
return !rows.some(row => row.instance_id !== instanceId && (row.bind_address === bind || ['0.0.0.0','::'].includes(bind) || ['0.0.0.0','::'].includes(row.bind_address)));
|
||||
}
|
||||
export function nextHostPort(hostId: string, additionalPorts: number[] = [], bindAddress?: string, protocol: 'udp' | 'tcp' = 'udp'): number | null {
|
||||
const host = requireHost(hostId);
|
||||
for (let candidate = host.portMin; candidate <= host.portMax; candidate++) if (!additionalPorts.includes(candidate) && hostPortAvailable(hostId, candidate, bindAddress, protocol)) return candidate;
|
||||
return null;
|
||||
}
|
||||
export function reserveHostPort(hostId: string, instanceId: string, options: { port?: number; bindAddress?: string; protocol?: 'udp' | 'tcp' } = {}): number {
|
||||
const host = requireHost(hostId); const bind = options.bindAddress ?? host.bindAddress; const protocol = options.protocol ?? 'udp';
|
||||
if (!isIP(bind)) throw new Error('Bind address must be an IP address.');
|
||||
if (!['udp','tcp'].includes(protocol)) throw new Error('Port protocol must be UDP or TCP.');
|
||||
if (!instanceId) throw new Error('An immutable instance ID is required.');
|
||||
return database().transaction(() => {
|
||||
const existing = database().prepare('SELECT port FROM ec_host_ports WHERE host_id=? AND instance_id=? AND bind_address=? AND protocol=?').get(hostId, instanceId, bind, protocol) as { port: number } | undefined;
|
||||
if (existing && (options.port === undefined || options.port === existing.port)) return existing.port;
|
||||
const allocated = options.port ?? nextHostPort(hostId, [], bind, protocol);
|
||||
if (allocated === null || !hostPortAvailable(hostId, allocated, bind, protocol, instanceId)) throw new Error('No available game port on the selected host.');
|
||||
database().prepare('INSERT INTO ec_host_ports(host_id,instance_id,bind_address,protocol,port,created_at) VALUES(?,?,?,?,?,?)').run(hostId, instanceId, bind, protocol, allocated, now());
|
||||
return allocated;
|
||||
})();
|
||||
}
|
||||
export function releaseHostPorts(instanceId: string): void { database().prepare('DELETE FROM ec_host_ports WHERE instance_id=?').run(instanceId); }
|
||||
@@ -0,0 +1,11 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
/** adapter-node copies public assets to build/client; Vite serves static in development. */
|
||||
export async function agentReleaseAsset(file: 'install.sh' | 'ec-agent.py' | 'manifest.json'): Promise<string> {
|
||||
try { return await readFile(resolve('build/client/agent', file), 'utf8'); }
|
||||
catch (cause) {
|
||||
if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') throw cause;
|
||||
return readFile(resolve('static/agent', file), 'utf8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
const initialized = new WeakSet<Database.Database>();
|
||||
export function ensureHostSchema(handle: Database.Database): void {
|
||||
if (initialized.has(handle)) return;
|
||||
handle.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ec_hosts (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, hostname TEXT NOT NULL DEFAULT '',
|
||||
mode TEXT NOT NULL DEFAULT 'active' CHECK (mode IN ('active','draining','disabled')),
|
||||
port_min INTEGER NOT NULL DEFAULT 28960, port_max INTEGER NOT NULL DEFAULT 28980,
|
||||
bind_address TEXT NOT NULL DEFAULT '0.0.0.0', public_address TEXT NOT NULL DEFAULT '',
|
||||
minimum_free_bytes INTEGER NOT NULL DEFAULT 1073741824,
|
||||
created_at TEXT NOT NULL, enrolled_at TEXT, last_heartbeat TEXT, inventory TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ec_host_enrollments (
|
||||
id TEXT PRIMARY KEY, host_id TEXT NOT NULL REFERENCES ec_hosts(id), token_hash TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL, expires_at TEXT NOT NULL, consumed_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ec_host_credentials (
|
||||
id TEXT PRIMARY KEY, host_id TEXT NOT NULL REFERENCES ec_hosts(id), token_hash TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL, revoked_at TEXT, last_used_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ec_host_operations (
|
||||
id TEXT PRIMARY KEY, host_id TEXT NOT NULL REFERENCES ec_hosts(id), kind TEXT NOT NULL,
|
||||
instance_id TEXT, generation INTEGER, idempotency_key TEXT,
|
||||
payload TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (state IN ('pending','accepted','running','completed','failed','unknown')),
|
||||
result TEXT, error TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_delivered_at TEXT,
|
||||
UNIQUE(host_id, idempotency_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ec_host_operations_queue ON ec_host_operations(host_id, state, created_at);
|
||||
CREATE TABLE IF NOT EXISTS ec_host_operation_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, operation_id TEXT NOT NULL REFERENCES ec_host_operations(id),
|
||||
state TEXT NOT NULL, created_at TEXT NOT NULL, error TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ec_host_ports (
|
||||
host_id TEXT NOT NULL REFERENCES ec_hosts(id), instance_id TEXT NOT NULL,
|
||||
bind_address TEXT NOT NULL, protocol TEXT NOT NULL CHECK (protocol IN ('udp','tcp')),
|
||||
port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535), created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(host_id,bind_address,protocol,port)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ec_host_ports_instance ON ec_host_ports(instance_id);
|
||||
`);
|
||||
const columns = handle.prepare('PRAGMA table_info(ec_host_operations)').all() as { name: string }[];
|
||||
if (!columns.some(column => column.name === 'last_delivered_at')) handle.exec('ALTER TABLE ec_host_operations ADD COLUMN last_delivered_at TEXT');
|
||||
initialized.add(handle);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { config, komodoConfigured } from '../config.js';
|
||||
import { getServer } from '../fleet/servers.js';
|
||||
import { nativeHealth, nativeInstance } from '../backends/native.js';
|
||||
|
||||
// Ports `komodo_restart` in push_scripts.sh:622. Three shapes depending on
|
||||
// the tracked komodo_type: deployment | container | stack.
|
||||
@@ -353,6 +354,7 @@ async function fetchJsonWithSnippet(
|
||||
* the container endpoint isn't available.
|
||||
*/
|
||||
export async function komodoInspectHealthy(serverName: string): Promise<KomodoHealthResult> {
|
||||
if (getServer(serverName)?.native_instance_id) return nativeHealth(serverName);
|
||||
if (!komodoConfigured()) return { ok: false, reason: 'komodo not configured' };
|
||||
const row = getServer(serverName);
|
||||
if (!row?.komodo_name) return { ok: false, reason: 'no komodo_name for server' };
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getServer, setServerField } from '../fleet/servers.js';
|
||||
import { latestGameSubmission } from '../fleet/lifecycle-evidence.js';
|
||||
import { komodoResolveGamePort } from './komodo.js';
|
||||
import { parsePlutoniumRevision } from '../../plutonium.js';
|
||||
import { nativeInstance, nativeRequest } from '../backends/native.js';
|
||||
|
||||
// Plutonium/CoD RCON is UDP with a 4-byte 0xff prefix. Requests look like
|
||||
// \xff\xff\xff\xffrcon <password> <command>
|
||||
@@ -373,6 +374,26 @@ export function formatRawReply(buf: Buffer): string {
|
||||
* rather than just "unreachable".
|
||||
*/
|
||||
export async function rconStatusResult(server: string): Promise<RconStatusResult> {
|
||||
const native = getServer(server)?.native_instance_id ? nativeInstance(server) : null;
|
||||
if (native) {
|
||||
const key = JSON.stringify(['agent', native.id, native.generation, latestGameSubmission(server)]);
|
||||
const existing = statusInFlight.get(key);
|
||||
if (existing) return existing;
|
||||
const endpoint = { host: `agent:${native.host_id}`, port: native.port };
|
||||
const request = (async (): Promise<RconStatusResult> => {
|
||||
try {
|
||||
const reply = await nativeRequest<{ text: string }>(native, 'rcon.execute', { command: 'status' });
|
||||
const players = parsePlayerCount(reply.text);
|
||||
if (players === null) return { ok: false, endpoint, reason: 'bad-reply', detail: 'Agent returned an unrecognized RCON status.', rawReply: reply.text.slice(0, 4000) };
|
||||
const info = parseInfoLine(reply.text); const header = parseStatusHeader(reply.text);
|
||||
return { ok: true, endpoint, status: { players,
|
||||
capacity: positiveCapacity(info.sv_maxclients ?? header.sv_maxclients),
|
||||
map: info.mapname ?? info.sv_mapname ?? header.map ?? header.mapname ?? header.sv_mapname ?? null,
|
||||
gametype: info.g_gametype ?? header.g_gametype ?? header.gametype ?? null, roster: parsePlayerRoster(reply.text) }, rawReply: reply.text.slice(0, 4000) };
|
||||
} catch (error) { return { ok: false, endpoint, reason: 'timeout', detail: (error as Error).message }; }
|
||||
})().finally(() => statusInFlight.delete(key));
|
||||
statusInFlight.set(key, request); return request;
|
||||
}
|
||||
// Snapshot before any network work. A probe begun after a restart or a
|
||||
// replacement registration must not join one observing the old process.
|
||||
const createdAt = getServer(server)?.created_at;
|
||||
@@ -476,6 +497,14 @@ function positiveCapacity(value: string | undefined): number | null {
|
||||
|
||||
/** Query the running process, not the updater's on-disk files. */
|
||||
export async function rconPlutoniumVersion(server: string): Promise<{ revision: number | null; error: string | null }> {
|
||||
const native = getServer(server)?.native_instance_id ? nativeInstance(server) : null;
|
||||
if (native) {
|
||||
try {
|
||||
const reply = await nativeRequest<{ text: string }>(native, 'rcon.execute', { command: 'version' });
|
||||
const revision = parsePlutoniumRevision(reply.text);
|
||||
return { revision, error: revision === null ? 'Agent RCON reply did not identify a runtime revision.' : null };
|
||||
} catch (error) { return { revision: null, error: (error as Error).message }; }
|
||||
}
|
||||
const { endpoint } = await endpointFor(server);
|
||||
if (!endpoint) return { revision: null, error: 'No RCON endpoint available.' };
|
||||
const response = await sendRcon(endpoint.host, endpoint.port, 'version');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { komodoConfigured } from '../config.js';
|
||||
import { nativeInstance } from '../backends/native.js';
|
||||
import { getServer, listServers } from '../fleet/servers.js';
|
||||
import { hasActiveProvisioning } from '../fleet/provisioning.js';
|
||||
import { komodoInspectHealthy } from '../integrations/komodo.js';
|
||||
@@ -295,7 +296,7 @@ async function run(forceScan: boolean): Promise<void> {
|
||||
const lastScan = setting('watchdog_last_scan');
|
||||
if (!forceScan && lastScan && Date.now() - Date.parse(lastScan) < WATCHDOG_SCAN_MS) return;
|
||||
setSetting('watchdog_last_scan', iso());
|
||||
if (!komodoConfigured()) {
|
||||
if (!komodoConfigured() && !listServers().some(server => nativeInstance(server.name))) {
|
||||
setSetting('watchdog_scan_error', 'Configure Komodo before automatic recovery. RCON or fresh telemetry verifies game status.'); return;
|
||||
}
|
||||
const errors: string[] = [];
|
||||
|
||||
@@ -6,12 +6,14 @@ import type { ProvisioningCapacity, ProvisioningDeployment, ProvisioningDetail,
|
||||
import { fleetSettings } from '../settings.js';
|
||||
import { activitySummary, deploymentEvents, latestFailureEvent } from './activity.js';
|
||||
import { readProgress, recordProgress, type ProgressUpdate } from './progress.js';
|
||||
import { bindNativeOperation } from './native.js';
|
||||
|
||||
export function beginDeployment(server: string, action: ProvisioningAction, triggeredBy = 'ui'): string {
|
||||
const id = randomUUID();
|
||||
db().transaction(() => {
|
||||
db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by)
|
||||
VALUES (?, ?, ?, 'running', 'Starting', ?, ?)`).run(id, server, action, new Date().toISOString(), triggeredBy);
|
||||
bindNativeOperation(id, server);
|
||||
recordProgress(id, { phase: 'requested', observedAt: null, checkpoint: { key: 'requested', phase: 'requested',
|
||||
label: action === 'create' ? 'Server creation requested'
|
||||
: action === 'destroy' ? 'Server destruction requested' : `${actionLabels[action]} server requested` } });
|
||||
@@ -243,10 +245,10 @@ export function listDeployments() {
|
||||
|
||||
export function interruptDeployments(): void {
|
||||
db().transaction(() => {
|
||||
const running = db().prepare("SELECT id FROM server_deployments WHERE state = 'running'").all() as { id: string }[];
|
||||
const running = db().prepare("SELECT id FROM server_deployments WHERE state = 'running' AND id NOT IN (SELECT deployment_id FROM native_provisioning)").all() as { id: string }[];
|
||||
const finishedAt = new Date().toISOString();
|
||||
const error = 'The application restarted before this operation finished. Check the server before retrying.';
|
||||
db().prepare(`UPDATE server_deployments SET state = 'interrupted', finished_at = ?, error = ? WHERE state = 'running'`).run(finishedAt, error);
|
||||
db().prepare(`UPDATE server_deployments SET state = 'interrupted', finished_at = ?, error = ? WHERE state = 'running' AND id NOT IN (SELECT deployment_id FROM native_provisioning)`).run(finishedAt, error);
|
||||
for (const { id } of running) recordProgress(id, { blocker: null, event: { kind: 'outcome',
|
||||
message: 'Interrupted by application restart. Check the server before retrying.', details: { state: 'interrupted', finishedAt, error } } });
|
||||
})();
|
||||
|
||||
@@ -28,6 +28,8 @@ import { getVersion } from '../fleet/scripts.js';
|
||||
import type { ProvisioningBlocker } from '../../provisioning-detail.js';
|
||||
import { recordProgress, type ProgressUpdate } from './progress.js';
|
||||
import { checkRelease, freshRelease, probeVersion, release } from '../integrations/plutonium.js';
|
||||
import { nativeInstance, nativeDeployment } from '../backends/native.js';
|
||||
import { previewNativeCreation, queueNativeCreation, queueNativeRebuild, cancelQueuedNativeCreation } from './native.js';
|
||||
|
||||
export type CreateServerFailure =
|
||||
| 'bad-request'
|
||||
@@ -49,6 +51,12 @@ export type DestroyServerResult =
|
||||
| { ok: false; kind: 'not-found' | 'conflict' | 'komodo' | 'fs' | 'db'; message: string; warnings: string[] };
|
||||
|
||||
export interface CreateServerInput {
|
||||
hostId?: string;
|
||||
libraryId?: string;
|
||||
image?: string;
|
||||
bindAddress?: string;
|
||||
publicAddress?: string;
|
||||
publicPort?: number;
|
||||
game?: ZombieGame; // omitted by older clients: T5
|
||||
gameMode: GameMode; // 'zombie' — only value supported today
|
||||
mapSlug: string; // 'cosmodrome'
|
||||
@@ -167,7 +175,8 @@ interface PreviewRow {
|
||||
}
|
||||
|
||||
/** Read-only checks; a preview never reserves a slot or writes credentials. */
|
||||
export function previewCreation(input: { game?: unknown; mapSlug?: unknown; blueprintId?: unknown; deploymentId?: unknown }): LaunchPreview {
|
||||
export function previewCreation(input: { game?: unknown; mapSlug?: unknown; blueprintId?: unknown; deploymentId?: unknown; hostId?: unknown; libraryId?: unknown }): LaunchPreview {
|
||||
if (input.hostId !== undefined || !config.komodo.address) return previewNativeCreation(input);
|
||||
if (input.blueprintId !== undefined && input.deploymentId !== undefined) throw new Error('Choose one starting point.');
|
||||
let recipe: LaunchRecipe;
|
||||
let blueprintName: string | null = null;
|
||||
@@ -208,6 +217,8 @@ export function previewCreation(input: { game?: unknown; mapSlug?: unknown; blue
|
||||
|
||||
// releasingServers is internal to atomic collection queueing; HTTP callers cannot set it.
|
||||
export function queueCreateServer(input: CreateServerInput, releasingServers: string[] = []): CreateServerResult {
|
||||
const nativePreview = input.previewId ? db().prepare("SELECT 1 FROM server_launch_previews WHERE id=? AND json_extract(recipe,'$.native.hostId') IS NOT NULL").get(input.previewId) : null;
|
||||
if (input.hostId || nativePreview || !config.komodo.address) return queueNativeCreation(input);
|
||||
return db().transaction((): CreateServerResult => {
|
||||
const preview = input.previewId ? db().prepare('SELECT * FROM server_launch_previews WHERE id = ?').get(input.previewId) as PreviewRow | undefined : undefined;
|
||||
if (input.previewId && !preview) return { ok: false, kind: 'bad-request', message: 'Launch preview expired or was not found. Review a fresh preview.' };
|
||||
@@ -243,6 +254,7 @@ export function queueCreateServer(input: CreateServerInput, releasingServers: st
|
||||
|
||||
/** Reserve the same identity and port before removing any part of the instance. */
|
||||
export function queueRebuildServer(name: string, triggeredBy = 'ui'): CreateServerResult {
|
||||
if (nativeInstance(name)) return queueNativeRebuild(name, triggeredBy);
|
||||
const blocker = activeBetaRemovalBlocker(name);
|
||||
if (blocker) return { ok: false, kind: 'conflict', message: blocker };
|
||||
const server = getServer(name);
|
||||
@@ -535,6 +547,8 @@ function hasActiveCreation(name: string): boolean {
|
||||
}
|
||||
|
||||
export function destroyServer(name: string, triggeredBy = 'ui'): DestroyServerResult & { deploymentId?: string } {
|
||||
const cancelled = cancelQueuedNativeCreation(name, triggeredBy);
|
||||
if (cancelled) return { ok: true, warnings: [], deploymentId: cancelled };
|
||||
const blocker = activeBetaRemovalBlocker(name);
|
||||
if (blocker) return { ok: false, kind: 'conflict', message: blocker, warnings: [] };
|
||||
if (db().prepare(`SELECT 1 FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action IN ('start','restart','stop','rebuild')
|
||||
@@ -597,6 +611,7 @@ async function runDestructions(): Promise<void> {
|
||||
}
|
||||
|
||||
async function processDestruction(operation: DestructionJob): Promise<void> {
|
||||
if (nativeDeployment(operation.id)) return;
|
||||
if (creating || destroying.has(operation.server)) return;
|
||||
if (!db().prepare("SELECT id FROM server_deployments WHERE id = ? AND state = 'waiting'").get(operation.id)) return;
|
||||
destroying.add(operation.server);
|
||||
@@ -859,6 +874,7 @@ async function runCreations(): Promise<CreationOutcomes> {
|
||||
// deployment. A known interrupted startup must retain its readiness gate.
|
||||
const interrupted = db().prepare("SELECT id FROM server_deployments WHERE server_deleted_at IS NULL AND action IN ('create', 'rebuild') AND state = 'interrupted' ORDER BY started_at, rowid").all() as { id: string }[];
|
||||
for (const { id } of interrupted) {
|
||||
if (nativeDeployment(id)) continue;
|
||||
if (resumeVerification(id)) continue;
|
||||
// A queued removal does not make the interrupted container disappear.
|
||||
// Keep its slot until removal finishes, without resuming normal work or
|
||||
@@ -883,6 +899,7 @@ async function runCreations(): Promise<CreationOutcomes> {
|
||||
WHERE d.server_deleted_at IS NULL AND d.state = 'awaiting-health' ORDER BY d.started_at, d.rowid`).all() as CreationJob[];
|
||||
for (const job of checking) {
|
||||
const id = job.deployment_id;
|
||||
if (nativeDeployment(id)) continue;
|
||||
if (getServer(job.server)?.game_port !== job.port) {
|
||||
finishDeployment(id, { ok: false, message: 'Server was removed or replaced before creation health checks completed' });
|
||||
continue;
|
||||
@@ -980,6 +997,7 @@ async function runCreations(): Promise<CreationOutcomes> {
|
||||
const queue = creationQueue();
|
||||
const available = Math.max(0, fleetSettings().createConcurrency - queue.active.length);
|
||||
const eligible = queue.queued.filter(({ id }) => {
|
||||
if (nativeDeployment(id)) return false;
|
||||
const blocker = creationBlocker(id);
|
||||
if (blocker) deploymentStage(id, blocker.message, { phase: 'startup-queue', blocker });
|
||||
else recordProgress(id, { phase: 'startup-queue', blocker: { code: available ? 'startup-queue' : 'startup-capacity',
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { posix } from 'node:path';
|
||||
import { db } from '../db/index.js';
|
||||
import { config, relDestForKind, serverDir } from '../config.js';
|
||||
import { nativeInstance, nativeRequest, type NativeInstance } from '../backends/native.js';
|
||||
import { getHost, getHostOperation, listHosts, nextHostPort, queueHostOperation, releaseHostPorts, requireReadyHost, reserveHostPort } from '../hosts/index.js';
|
||||
import { getLibraryVersion, requireReadyLibrary } from '../game-files/index.js';
|
||||
import { blueprintRecipe, captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from '../fleet/launch-recipes.js';
|
||||
import { getServerKey, saveServerKey } from '../fleet/server-keys.js';
|
||||
import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js';
|
||||
import { nextLobbyNumber, type CreateServerInput, type CreateServerResult } from './instances.js';
|
||||
import { beginDeployment, deploymentStage, finishDeployment, REMOVING_SERVER } from './history.js';
|
||||
import { recordProgress } from './progress.js';
|
||||
import { fleetSettings } from '../settings.js';
|
||||
import { resolveRecoveredHealthOperation } from '../maintenance/recovery.js';
|
||||
import { probeServerStatus, probePlayerEvidence } from '../fleet/status-evidence.js';
|
||||
import { checkRelease, freshRelease, probeVersion } from '../integrations/plutonium.js';
|
||||
import { getServerScript, recordScript, removeScript } from '../fleet/inventory.js';
|
||||
import { sha256Buffer } from '../fleet/files.js';
|
||||
import type { StagedFileChange } from '../fleet/staged-files.js';
|
||||
import type { LaunchPreview } from '../../deployment-plan.js';
|
||||
import type { ProvisioningAction } from '../../provisioning.js';
|
||||
|
||||
export interface NativeSelection { hostId: string; libraryId: string; image?: string; bindAddress?: string; publicAddress?: string; publicPort?: number }
|
||||
type NativeRecipe = LaunchRecipe & { native?: NativeSelection };
|
||||
interface Job { id: string; server: string; action: ProvisioningAction; state: string; submitted_at: string | null; stage: string;
|
||||
instance_id: string; generation: number; stage_operation_id: string | null; operation_id: string | null; files_json: string | null; applied_at: string | null }
|
||||
interface FilePlan { id: number; path: string; contentBase64: string | null; sha256: string | null; checkBefore: boolean; expectedBeforeSha: string | null }
|
||||
|
||||
export function nativeSelection(input: Record<string, unknown>, game: string): NativeSelection {
|
||||
if (typeof input.hostId !== 'string' || !input.hostId) throw new Error('Select a ready host.');
|
||||
if (typeof input.libraryId !== 'string' || !input.libraryId) throw new Error('Select a published game-file version.');
|
||||
const version = getLibraryVersion(input.libraryId);
|
||||
if (!version || version.game !== game || version.state !== 'ready') throw new Error('Choose a published game-file version for the selected game.');
|
||||
const image = input.image === undefined || input.image === '' ? config.komodo.defaultImage : input.image;
|
||||
if (typeof image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,499}$/.test(image)) throw new Error('Invalid runtime image.');
|
||||
const host = requireReadyHost(input.hostId, { image, requiredBytes: 1024 ** 3 });
|
||||
requireReadyLibrary(host.id, version.id);
|
||||
const bindAddress = input.bindAddress === undefined || input.bindAddress === '' ? host.bindAddress : input.bindAddress;
|
||||
if (typeof bindAddress !== 'string') throw new Error('Invalid bind address.');
|
||||
if (input.publicAddress !== undefined && (typeof input.publicAddress !== 'string' || input.publicAddress.length > 253)) throw new Error('Invalid public address.');
|
||||
if (input.publicPort !== undefined && (!Number.isInteger(input.publicPort) || Number(input.publicPort) < 1 || Number(input.publicPort) > 65535)) throw new Error('Invalid public port.');
|
||||
return { hostId: host.id, libraryId: version.id, image, bindAddress, publicAddress: input.publicAddress as string | undefined ?? host.publicAddress,
|
||||
publicPort: input.publicPort as number | undefined };
|
||||
}
|
||||
|
||||
export function previewNativeCreation(input: Record<string, unknown>): LaunchPreview {
|
||||
let recipe: NativeRecipe;
|
||||
let blueprintName: string | null = null;
|
||||
if (typeof input.deploymentId === 'string') {
|
||||
const previous = recipeForDeployment(input.deploymentId);
|
||||
if (!previous) throw new Error('The recorded deployment is unavailable.');
|
||||
recipe = captureRecipe(previous.recipe.game, previous.recipe.mapSlug);
|
||||
blueprintName = previous.blueprintName;
|
||||
} else if (typeof input.blueprintId === 'string') ({ recipe, blueprintName } = blueprintRecipe(input.blueprintId));
|
||||
else {
|
||||
if ((input.game !== 't5' && input.game !== 't6') || typeof input.mapSlug !== 'string') throw new Error('Choose a supported game and map.');
|
||||
recipe = captureRecipe(input.game, input.mapSlug);
|
||||
}
|
||||
const selection = nativeSelection(input, recipe.game);
|
||||
recipe.native = selection; recipe.image = selection.image!;
|
||||
const base = `${recipe.game}_zombie_${recipe.mapSlug}`;
|
||||
const lobby = nextLobbyNumber(base);
|
||||
const name = lobby === null ? null : `${base}_${lobby}`;
|
||||
const port = nextNativePort(selection);
|
||||
const id = randomUUID(); const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString();
|
||||
db().prepare('INSERT INTO server_launch_previews(id,expires_at,server,port,recipe,blueprint_name) VALUES(?,?,?,?,?,?)')
|
||||
.run(id, expiresAt, name, port, JSON.stringify(recipe), blueprintName);
|
||||
const checks: LaunchPreview['checks'] = [
|
||||
{ label: 'Host readiness', state: 'pass', detail: `${getHost(selection.hostId)!.name}: connected, Docker ready, storage and runtime verified.` },
|
||||
{ label: 'Game files', state: 'pass', detail: `Immutable library version ${selection.libraryId} is ready on the selected host.` },
|
||||
{ label: 'Lobby', state: name ? 'pass' : 'blocked', detail: name ?? 'All lobby IDs for this map are reserved.' },
|
||||
{ label: 'Host port', state: port ? 'pass' : 'blocked', detail: port ? `${selection.bindAddress}:${port} / UDP and TCP` : 'No available UDP/TCP port on this host.' },
|
||||
{ label: 'Configuration', state: 'pass', detail: recipe.configSource }
|
||||
];
|
||||
return { id, expiresAt, name, port, hasSavedKey: !!name && !!getServerKey(name), recipe: summarizeRecipe(recipe), blueprintName, checks,
|
||||
ready: checks.every(check => check.state !== 'blocked') };
|
||||
}
|
||||
|
||||
export function nextNativePort(selection: NativeSelection, taken: number[] = []): number | null {
|
||||
const excluded = [...taken];
|
||||
for (;;) {
|
||||
const port = nextHostPort(selection.hostId, excluded, selection.bindAddress, 'udp');
|
||||
if (port === null) return null;
|
||||
// Both protocols are published by Plutainer and reserved together.
|
||||
const tcp = nextHostPort(selection.hostId, excluded, selection.bindAddress, 'tcp');
|
||||
if (tcp === port) return port;
|
||||
if (tcp !== null && tcp < port) { excluded.push(tcp); continue; }
|
||||
excluded.push(port);
|
||||
}
|
||||
}
|
||||
|
||||
export function queueNativeCreation(input: CreateServerInput): CreateServerResult {
|
||||
try {
|
||||
return db().transaction(() => {
|
||||
const preview = input.previewId ? db().prepare('SELECT * FROM server_launch_previews WHERE id=?').get(input.previewId) as
|
||||
{ id: string; recipe: string; server: string; port: number; expires_at: string; deployment_id: string | null; blueprint_name: string | null } | undefined : undefined;
|
||||
if (input.previewId && !preview) throw new Error('Review a fresh launch preview.');
|
||||
if (preview?.deployment_id) return { ok: true as const, name: preview.server, port: preview.port, deploymentId: preview.deployment_id };
|
||||
if (preview && preview.expires_at <= new Date().toISOString()) throw new Error('Launch preview expired.');
|
||||
const saved = preview ? JSON.parse(preview.recipe) as NativeRecipe : undefined;
|
||||
const game = input.game ?? 't5';
|
||||
if (input.gameMode !== 'zombie' || (saved && (saved.game !== game || saved.mapSlug !== input.mapSlug))) throw new Error('Selection changed. Review a fresh launch preview.');
|
||||
const selection = nativeSelection((saved?.native ?? input) as unknown as Record<string, unknown>, game);
|
||||
const recipe: NativeRecipe = { ...captureRecipe(game, input.mapSlug), native: selection, image: selection.image! };
|
||||
const lobby = nextLobbyNumber(`${game}_zombie_${input.mapSlug}`);
|
||||
if (lobby === null) throw new Error('All lobby IDs are reserved.');
|
||||
const name = `${game}_zombie_${input.mapSlug}_${lobby}`;
|
||||
const port = nextNativePort(selection);
|
||||
if (!port) throw new Error('No available host port.');
|
||||
if ((input.expectedName && input.expectedName !== name) || (preview && (preview.server !== name || preview.port !== port))) throw new Error('Available lobby or host port changed. Review a fresh preview.');
|
||||
const key = input.plutoKey?.trim() || getServerKey(name);
|
||||
if (!key) throw new Error(`Save a Plutonium server key for ${name}.`);
|
||||
saveServerKey(name, key);
|
||||
const instanceId = randomUUID();
|
||||
reserveHostPort(selection.hostId, instanceId, { port, bindAddress: selection.bindAddress, protocol: 'udp' });
|
||||
reserveHostPort(selection.hostId, instanceId, { port, bindAddress: selection.bindAddress, protocol: 'tcp' });
|
||||
db().prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,bind_address,port,public_address,public_port,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`).run(instanceId, name, selection.hostId, selection.libraryId, selection.image, selection.bindAddress, port,
|
||||
selection.publicAddress ?? null, selection.publicPort ?? null, new Date().toISOString());
|
||||
const id = beginDeployment(name, 'create', input.createdBy);
|
||||
db().prepare("UPDATE server_deployments SET state='queued' WHERE id=?").run(id);
|
||||
db().prepare('INSERT INTO server_creation_jobs(deployment_id,server,port,game_mode,map_slug,expected_map) VALUES(?,?,?,?,?,?)')
|
||||
.run(id, name, port, 'zombie', recipe.mapSlug, recipe.expectedMap);
|
||||
db().prepare('INSERT INTO server_launch_recipes(deployment_id,recipe,blueprint_name) VALUES(?,?,?)').run(id, JSON.stringify(recipe), preview?.blueprint_name ?? null);
|
||||
if (preview) db().prepare('UPDATE server_launch_previews SET deployment_id=? WHERE id=?').run(id, preview.id);
|
||||
deploymentStage(id, 'Queued for native host deployment', { phase: 'startup-queue', recipeStatus: 'queued' });
|
||||
return { ok: true as const, name, port, deploymentId: id };
|
||||
})();
|
||||
} catch (error) { return { ok: false, kind: 'bad-request', message: (error as Error).message }; }
|
||||
}
|
||||
|
||||
export function bindNativeOperation(id: string, server: string): void {
|
||||
const instance = nativeInstance(server);
|
||||
if (instance) db().prepare('INSERT OR IGNORE INTO native_provisioning(deployment_id,instance_id,generation) VALUES(?,?,?)').run(id, instance.id, instance.generation);
|
||||
}
|
||||
|
||||
/** Cancelling an unsubmitted launch releases only its local reservation. */
|
||||
export function cancelQueuedNativeCreation(server: string, triggeredBy: string): string | null {
|
||||
const instance = nativeInstance(server);
|
||||
if (!instance || getServer(server)) return null;
|
||||
return db().transaction(() => {
|
||||
const creation = db().prepare(`SELECT d.id FROM server_deployments d JOIN native_provisioning n ON n.deployment_id=d.id
|
||||
WHERE n.instance_id=? AND d.action='create' AND d.state='queued' AND n.operation_id IS NULL`).get(instance.id) as { id: string } | undefined;
|
||||
if (!creation) return null;
|
||||
const id = beginDeployment(server, 'destroy', triggeredBy);
|
||||
const now = new Date().toISOString();
|
||||
db().prepare("UPDATE server_deployments SET state='cancelled',finished_at=?,stage='Queued native creation cancelled',server_deleted_at=? WHERE id=?")
|
||||
.run(now, now, creation.id);
|
||||
db().prepare('UPDATE native_instances SET retired_at=? WHERE id=?').run(now, instance.id);
|
||||
releaseHostPorts(instance.id);
|
||||
deploymentStage(id, 'Cancelled queued creation; no agent mutation was submitted');
|
||||
finishDeployment(id, { ok: true });
|
||||
return id;
|
||||
})();
|
||||
}
|
||||
|
||||
export function queueNativeRebuild(name: string, triggeredBy: string): CreateServerResult {
|
||||
try {
|
||||
const instance = nativeInstance(name)!; const server = getServer(name);
|
||||
const blocker = activeBetaRemovalBlocker(name);
|
||||
if (blocker) throw new Error(blocker);
|
||||
if (!server?.map_slug || !getServerKey(name)) throw new Error('A saved map and server key are required before rebuilding.');
|
||||
requireReadyLibrary(instance.host_id, instance.library_id);
|
||||
const recipe: NativeRecipe = { ...captureRecipe(server.game as 't5' | 't6', server.map_slug), image: instance.image,
|
||||
native: { hostId: instance.host_id, libraryId: instance.library_id, image: instance.image, bindAddress: instance.bind_address } };
|
||||
return db().transaction(() => {
|
||||
const id = beginDeployment(name, 'rebuild', triggeredBy);
|
||||
db().prepare("UPDATE server_deployments SET state='waiting' WHERE id=?").run(id);
|
||||
db().prepare('INSERT INTO server_creation_jobs(deployment_id,server,port,game_mode,map_slug,expected_map) VALUES(?,?,?,?,?,?)')
|
||||
.run(id, name, instance.port, 'zombie', recipe.mapSlug, recipe.expectedMap);
|
||||
db().prepare('INSERT INTO server_launch_recipes(deployment_id,recipe) VALUES(?,?)').run(id, JSON.stringify(recipe));
|
||||
deploymentStage(id, 'Waiting to rebuild when empty', { phase: 'eligibility', recipeStatus: 'queued' });
|
||||
return { ok: true as const, name, port: instance.port, deploymentId: id };
|
||||
})();
|
||||
} catch (error) { return { ok: false, kind: 'bad-request', message: (error as Error).message }; }
|
||||
}
|
||||
|
||||
function filesFor(job: Job): FilePlan[] {
|
||||
const rows = db().prepare(`SELECT f.* FROM staged_file_changes f JOIN provisioning_sources s
|
||||
ON s.kind=f.source_kind AND s.source_id=f.source_id AND s.server=f.server
|
||||
WHERE s.operation_id=? AND f.server=? AND f.state='pending' ORDER BY f.id`).all(job.id, job.server) as StagedFileChange[];
|
||||
return rows.map(row => ({ id: row.id, path: posix.join(relDestForKind(row.kind, job.server.startsWith('t6_') ? 't6' : 't5'), row.rel_path),
|
||||
contentBase64: row.content?.toString('base64') ?? null, sha256: row.content ? sha256Buffer(row.content) : null,
|
||||
checkBefore: !!row.check_before, expectedBeforeSha: row.expected_before_sha }));
|
||||
}
|
||||
|
||||
/** Agent receipts are the authoritative before images; do not read a controller mount. */
|
||||
function applyReceipt(job: Job, result: Record<string, unknown>) {
|
||||
if (job.applied_at || !job.files_json) return;
|
||||
const plans = JSON.parse(job.files_json) as FilePlan[];
|
||||
const before = result.before as { id?: number; path: string; contentBase64: string | null }[] | undefined;
|
||||
db().transaction(() => {
|
||||
for (const plan of plans) {
|
||||
const snapshot = before?.find(item => item.id === plan.id || item.path === plan.path);
|
||||
if (!snapshot) throw new Error('Agent activation receipt is missing a before image.');
|
||||
const row = db().prepare('SELECT * FROM staged_file_changes WHERE id=?').get(plan.id) as StagedFileChange;
|
||||
if (!row || row.state === 'applied') continue;
|
||||
const bytes = snapshot.contentBase64 === null ? null : Buffer.from(snapshot.contentBase64, 'base64');
|
||||
const prior = getServerScript(job.server, row.rel_path);
|
||||
if (row.source_kind === 'beta') {
|
||||
if (bytes !== null) db().prepare(`INSERT OR IGNORE INTO beta_file_snapshots(beta_id,server,rel_path,prior_content,prior_sha256,prior_size) VALUES(?,?,?,?,?,?)`)
|
||||
.run(row.source_id, job.server, row.rel_path, bytes, sha256Buffer(bytes), bytes.length);
|
||||
db().prepare(`UPDATE beta_server_scripts SET had_original=?,prior_version_id=? WHERE beta_id=? AND server=?
|
||||
AND script_id IN (SELECT id FROM scripts WHERE target_rel_path=? AND kind=?)`).run(bytes === null ? 0 : 1, prior?.script_version_id ?? null, row.source_id, job.server, row.rel_path, row.kind);
|
||||
db().prepare('INSERT OR IGNORE INTO beta_file_changes(beta_id,server,rel_path) VALUES(?,?,?)').run(row.source_id, job.server, row.rel_path);
|
||||
db().prepare('UPDATE beta_servers SET deployed_at=?,updated_at=? WHERE beta_id=? AND server=?').run(new Date().toISOString(), new Date().toISOString(), row.source_id, job.server);
|
||||
}
|
||||
if (row.deployment_file_id && !row.preserve_before_image) db().prepare('UPDATE deployment_files SET verb=?,content_before=?,sha256_before=? WHERE id=?')
|
||||
.run(row.content === null ? 'removed' : bytes === null ? 'added' : 'updated', bytes, bytes === null ? null : sha256Buffer(bytes), row.deployment_file_id);
|
||||
if (row.content === null) removeScript(job.server, row.rel_path);
|
||||
else recordScript(job.server, row.rel_path, row.script_version_id && db().prepare('SELECT 1 FROM script_versions WHERE id=?').get(row.script_version_id) ? row.script_version_id : null,
|
||||
sha256Buffer(row.content), row.content.length, row.inventory_source);
|
||||
db().prepare("UPDATE staged_file_changes SET state='applied',applied_at=? WHERE id=?").run(new Date().toISOString(), row.id);
|
||||
}
|
||||
db().prepare('UPDATE native_provisioning SET applied_at=? WHERE deployment_id=?').run(new Date().toISOString(), job.id);
|
||||
})();
|
||||
}
|
||||
|
||||
function createPayload(instance: NativeInstance, recipe: LaunchRecipe) {
|
||||
const key = getServerKey(instance.server);
|
||||
if (!key) throw new Error('The saved server key was removed.');
|
||||
return { name: instance.server, game: recipe.game, libraryId: instance.library_id, image: instance.image, port: instance.port, bindAddress: instance.bind_address,
|
||||
rconPassword: config.rcon.password || randomBytes(32).toString('hex'),
|
||||
env: { PLUTAINER_SERVER_NAME: instance.server, PLUTAINER_PORT: String(instance.port), PLUTAINER_GAME: recipe.game === 't6' ? 't6zm' : 't5sp',
|
||||
PLUTAINER_CONFIG_FILE: recipe.configFile, PLUTAINER_MAP_ROTATE: 'false', PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +set tws_server_id ${instance.server} +map_rotate`, PLUTO_SERVER_KEY: key },
|
||||
files: recipe.files.map(file => ({ path: posix.join(relDestForKind(file.kind, recipe.game), file.path), contentBase64: file.content, sha256: file.sha256 })) };
|
||||
}
|
||||
|
||||
function hold(job: Job, message: string) { deploymentStage(job.id, message, { blocker: { code: 'native-host', message, automatic: true } }); }
|
||||
function complete(job: Job) {
|
||||
db().transaction(() => {
|
||||
db().prepare('UPDATE native_provisioning SET verified_at=? WHERE deployment_id=?').run(new Date().toISOString(), job.id);
|
||||
db().prepare('UPDATE restart_rollout SET healthy_at=?,error=NULL WHERE operation_id=?').run(new Date().toISOString(), job.id);
|
||||
finishDeployment(job.id, { ok: true });
|
||||
})();
|
||||
}
|
||||
|
||||
export async function processNativeProvisioning(eligibility: (operation: { id: string; server: string; action: string; state: string; stage: string }) => string | null): Promise<void> {
|
||||
// Draining prevents admission immediately and retires existing lobbies only through the normal safety queue.
|
||||
for (const host of listHosts().filter(host => host.mode === 'draining' && host.connected)) {
|
||||
const instances = db().prepare('SELECT * FROM native_instances WHERE host_id=? AND retired_at IS NULL').all(host.id) as NativeInstance[];
|
||||
for (const instance of instances) {
|
||||
if (!getServer(instance.server) || activeBetaRemovalBlocker(instance.server)) continue;
|
||||
if (db().prepare("SELECT 1 FROM server_deployments WHERE server=? AND server_deleted_at IS NULL AND state IN ('queued','waiting','running','awaiting-health')").get(instance.server)) continue;
|
||||
const id = beginDeployment(instance.server, 'destroy', 'host-drain');
|
||||
db().prepare("UPDATE server_deployments SET state='waiting' WHERE id=?").run(id);
|
||||
deploymentStage(id, 'Host draining; waiting for a fresh empty lobby');
|
||||
}
|
||||
}
|
||||
const jobs = db().prepare(`SELECT d.*,n.instance_id,n.generation,n.stage_operation_id,n.operation_id,n.files_json,n.applied_at
|
||||
FROM native_provisioning n JOIN server_deployments d ON d.id=n.deployment_id
|
||||
WHERE d.server_deleted_at IS NULL AND d.state IN ('queued','waiting','running','awaiting-health','interrupted') ORDER BY d.started_at,d.rowid`).all() as Job[];
|
||||
for (const job of jobs) {
|
||||
try { await processJob(job, eligibility); }
|
||||
catch (error) { hold(job, (error as Error).message); }
|
||||
}
|
||||
}
|
||||
|
||||
async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
||||
let instance = nativeInstance(job.server);
|
||||
if (!instance || instance.id !== job.instance_id || (instance.generation !== job.generation && !(job.action === 'rebuild' && instance.generation === job.generation + 1))) {
|
||||
finishDeployment(job.id, { ok: false, message: 'Original instance was retired or replaced; no command was sent.' }); return;
|
||||
}
|
||||
const host = getHost(instance.host_id);
|
||||
if (!host?.connected || host.mode === 'disabled') {
|
||||
db().prepare("UPDATE servers SET container_state='unknown',last_players=NULL,last_roster=NULL WHERE name=?").run(job.server);
|
||||
hold(job, 'Host is unavailable; existing server state is unknown. Waiting for reconnection.'); return;
|
||||
}
|
||||
if (job.operation_id) {
|
||||
const operation = getHostOperation(job.operation_id);
|
||||
if (!operation || operation.state === 'unknown') { hold(job, 'Agent result is unknown. Reconciling the original operation; no replacement or repeated command will be sent.'); return; }
|
||||
if (operation.state === 'failed') {
|
||||
// The agent reports failed only when no mutation began. Ambiguous
|
||||
// post-mutation failures remain unknown and retain their capacity.
|
||||
db().transaction(() => {
|
||||
db().prepare('DELETE FROM restart_rollout WHERE operation_id=?').run(job.id);
|
||||
const files = JSON.parse(job.files_json ?? '[]') as FilePlan[];
|
||||
for (const file of files) db().prepare("UPDATE staged_file_changes SET state='pending' WHERE id=? AND state='applying'").run(file.id);
|
||||
finishDeployment(job.id, { ok: false, message: operation.error ?? 'Agent operation failed before mutation.' });
|
||||
})();
|
||||
return;
|
||||
}
|
||||
if (operation.state !== 'completed') {
|
||||
const blocked = operation.result as { message?: string } | null;
|
||||
hold(job, blocked?.message ?? `Agent operation ${operation.state}`); return;
|
||||
}
|
||||
if (job.action === 'destroy') {
|
||||
db().transaction(() => {
|
||||
deploymentStage(job.id, REMOVING_SERVER, { checkpoint: { key: 'delete-deployment', label: 'Agent confirmed instance and container removal' } });
|
||||
db().prepare('UPDATE native_instances SET retired_at=? WHERE id=?').run(new Date().toISOString(), instance!.id);
|
||||
releaseHostPorts(instance!.id);
|
||||
db().prepare('DELETE FROM servers WHERE name=?').run(job.server);
|
||||
complete(job);
|
||||
})();
|
||||
return;
|
||||
}
|
||||
if (job.action === 'rebuild' && instance.generation === job.generation) {
|
||||
db().prepare('UPDATE native_instances SET generation=? WHERE id=? AND generation=?').run(job.generation + 1, instance.id, job.generation);
|
||||
instance = nativeInstance(job.server)!;
|
||||
}
|
||||
applyReceipt(job, (operation.result ?? {}) as Record<string, unknown>);
|
||||
if (job.state !== 'awaiting-health') {
|
||||
db().prepare("UPDATE server_deployments SET state='awaiting-health',error=NULL,finished_at=NULL WHERE id=?").run(job.id);
|
||||
recordProgress(job.id, { command: { state: 'accepted', message: `Agent confirmed ${job.action}; verifying the resulting instance.` }, phase: 'verify-container' });
|
||||
}
|
||||
const health = await nativeRequest<{ healthy: boolean; state: string }>(instance, 'instance.inspect');
|
||||
if (job.action === 'stop') {
|
||||
if (!['stopped','exited','created','dead'].includes(health.state)) { hold(job, 'Waiting for the agent to verify a stopped container.'); return; }
|
||||
db().prepare("UPDATE servers SET container_state=?,last_players=NULL,last_roster=NULL WHERE name=?").run(health.state, job.server);
|
||||
complete(job); return;
|
||||
}
|
||||
if (!health.healthy) { hold(job, `Waiting for container readiness: ${health.state}`); return; }
|
||||
const status = await probeServerStatus(job.server, operation.updatedAt);
|
||||
if (!status.ok) { hold(job, `Waiting for game status: ${status.reason}`); return; }
|
||||
if (job.action === 'create' || job.action === 'rebuild') {
|
||||
const recipe = recipeForDeployment(job.id)!.recipe;
|
||||
if (status.map !== recipe.expectedMap) { hold(job, `Waiting for selected map ${recipe.expectedMap}; observed ${status.map ?? 'unknown'}.`); return; }
|
||||
await checkRelease();
|
||||
const revision = await probeVersion(job.server); const target = freshRelease();
|
||||
if (revision === null || target === null) { hold(job, 'Waiting to verify the Plutonium runtime revision.'); return; }
|
||||
if (revision < target) { finishDeployment(job.id, { ok: false, message: `Running Plutonium r${revision} is older than production r${target}.` }); return; }
|
||||
}
|
||||
db().prepare("UPDATE servers SET container_state='running',container_checked_at=? WHERE name=?").run(new Date().toISOString(), job.server);
|
||||
recordProgress(job.id, { blocker: null, checks: [{ key: 'container', label: 'Container readiness', source: 'agent', result: 'pass', actual: health.state },
|
||||
{ key: 'game', label: 'Game status', source: status.source, result: 'pass', actual: status.players }] });
|
||||
complete(job); return;
|
||||
}
|
||||
const reason = eligibility(job);
|
||||
if (reason) { hold(job, reason); return; }
|
||||
if (job.action === 'destroy' || job.action === 'rebuild') {
|
||||
const beta = activeBetaRemovalBlocker(job.server); if (beta) { hold(job, beta); return; }
|
||||
}
|
||||
const files = job.files_json ? JSON.parse(job.files_json) as FilePlan[] : filesFor(job);
|
||||
if (files.length && !job.stage_operation_id) {
|
||||
const stage = queueHostOperation(instance.host_id, 'files.stage', { stageId: job.id, files }, { instanceId: instance.id, generation: instance.generation, idempotencyKey: `${job.id}:stage` });
|
||||
db().prepare('UPDATE native_provisioning SET stage_operation_id=?,files_json=? WHERE deployment_id=?').run(stage.id, JSON.stringify(files), job.id);
|
||||
hold(job, 'Staging and verifying files on the host.'); return;
|
||||
}
|
||||
if (job.stage_operation_id) {
|
||||
const stage = getHostOperation(job.stage_operation_id);
|
||||
if (stage?.state === 'failed') { finishDeployment(job.id, { ok: false, message: stage.error ?? 'File staging failed.' }); return; }
|
||||
if (stage?.state !== 'completed') { hold(job, 'Waiting for verified remote file staging.'); return; }
|
||||
if (files.some(file => (db().prepare('SELECT state FROM staged_file_changes WHERE id=?').get(file.id) as { state: string } | undefined)?.state !== 'pending')) {
|
||||
finishDeployment(job.id, { ok: false, message: 'Staged file request was cancelled or changed before activation.' }); return;
|
||||
}
|
||||
}
|
||||
const settings = fleetSettings();
|
||||
const count = (actions: string[]) => (db().prepare(`SELECT COUNT(*) AS n FROM server_deployments WHERE id != ? AND server_deleted_at IS NULL
|
||||
AND action IN (${actions.map(() => '?').join(',')}) AND state IN ('running','awaiting-health')`).get(job.id, ...actions) as { n: number }).n;
|
||||
if ((job.action === 'create' || job.action === 'rebuild') && count(['create','rebuild']) >= settings.createConcurrency) { hold(job, 'Waiting for startup capacity.'); return; }
|
||||
if (job.action === 'destroy' && count(['destroy']) >= settings.destroyConcurrency) { hold(job, 'Waiting for destruction capacity.'); return; }
|
||||
if (job.action === 'create') requireReadyHost(instance.host_id, { image: instance.image, requiredBytes: 1024 ** 3 });
|
||||
if (job.action === 'create' || job.action === 'rebuild') requireReadyLibrary(instance.host_id, instance.library_id);
|
||||
if (job.action !== 'create' && job.action !== 'start') {
|
||||
// Fast controller check keeps occupied lobbies out of the operation slots.
|
||||
// The agent repeats this immediately before its local mutation.
|
||||
const health = await nativeRequest<{ state: string; healthy?: boolean }>(instance, 'instance.inspect');
|
||||
if (!['stopped','exited','created','dead','missing'].includes(health.state)) {
|
||||
const occupancy = await probePlayerEvidence(job.server);
|
||||
const recoveredWatchdog = health.healthy && occupancy.players !== null && db().prepare(`SELECT 1 FROM provisioning_sources s
|
||||
JOIN watchdog_incidents i ON i.id=s.source_id JOIN server_deployments d ON d.id=s.operation_id
|
||||
WHERE s.operation_id=? AND s.kind='watchdog' AND i.kind='health' AND d.triggered_by LIKE 'watchdog:%'`).get(job.id);
|
||||
if (recoveredWatchdog && occupancy.players !== null) { resolveRecoveredHealthOperation(job.id, occupancy.players, occupancy.source); return; }
|
||||
if (occupancy.players !== 0) { hold(job, occupancy.players !== null ? `Waiting for ${occupancy.players} player(s) to leave.` : 'Waiting for a fresh local player count.'); return; }
|
||||
}
|
||||
}
|
||||
const current = db().prepare('SELECT state FROM server_deployments WHERE id=? AND server_deleted_at IS NULL').get(job.id) as { state: string } | undefined;
|
||||
if (!current || !['queued','waiting','interrupted'].includes(current.state) || eligibility(job)) return;
|
||||
db().transaction(() => {
|
||||
const live = nativeInstance(job.server);
|
||||
if (live?.id !== instance!.id || live.generation !== instance!.generation) throw new Error('Instance changed during preflight.');
|
||||
if (job.action === 'destroy' || job.action === 'rebuild') {
|
||||
const beta = activeBetaRemovalBlocker(job.server);
|
||||
if (beta) { hold(job, beta); return; }
|
||||
}
|
||||
if (JSON.stringify(filesFor(job)) !== JSON.stringify(files)) {
|
||||
finishDeployment(job.id, { ok: false, message: 'File requests changed during preflight. Review and submit a fresh activation.' }); return;
|
||||
}
|
||||
if (job.action === 'start' || job.action === 'restart' || job.action === 'rebuild') {
|
||||
db().prepare(`INSERT INTO restart_rollout(server,requested_at,operation_id) VALUES(?,?,?) ON CONFLICT(server)
|
||||
DO UPDATE SET requested_at=excluded.requested_at,operation_id=excluded.operation_id,healthy_at=NULL,error=NULL`).run(job.server, new Date().toISOString(), job.id);
|
||||
}
|
||||
let payload: Record<string, unknown> = files.length ? { stageId: job.id } : {};
|
||||
if (job.action === 'create' || job.action === 'rebuild') {
|
||||
const prior = recipeForDeployment(job.id)!.recipe as NativeRecipe;
|
||||
const recipe: NativeRecipe = { ...captureRecipe(prior.game, prior.mapSlug), native: prior.native, image: instance!.image };
|
||||
payload = createPayload(instance!, recipe);
|
||||
if (job.action === 'rebuild') payload.nextGeneration = instance!.generation + 1;
|
||||
db().prepare('UPDATE server_launch_recipes SET recipe=? WHERE deployment_id=?').run(JSON.stringify(recipe), job.id);
|
||||
if (job.action === 'create') db().prepare(`INSERT INTO servers(name,komodo_type,komodo_name,game,map,game_mode,map_slug,host_dir,config_file,game_id,image,game_port,created_by,container_state)
|
||||
VALUES(?,'deployment','',?,?,'zombie',?,?,?,?,?,?,'native','starting')`).run(job.server, recipe.game, recipe.expectedMap, recipe.mapSlug, serverDir(job.server),
|
||||
recipe.configFile, recipe.game === 't6' ? 't6zm' : 't5sp', recipe.image, instance!.port);
|
||||
for (const file of recipe.files) recordScript(job.server, file.path, file.versionId, file.sha256, file.size, 'native-create');
|
||||
}
|
||||
const operation = queueHostOperation(instance!.host_id, `instance.${job.action === 'destroy' ? 'remove' : job.action}`, payload,
|
||||
{ instanceId: instance!.id, generation: instance!.generation, idempotencyKey: job.id });
|
||||
db().prepare('UPDATE native_provisioning SET operation_id=? WHERE deployment_id=?').run(operation.id, job.id);
|
||||
for (const file of files) db().prepare("UPDATE staged_file_changes SET state='applying' WHERE id=? AND state='pending'").run(file.id);
|
||||
db().prepare("UPDATE server_deployments SET state='running',submitted_at=? WHERE id=?").run(new Date().toISOString(), job.id);
|
||||
deploymentStage(job.id, `Host agent ${job.action} queued`, { phase: 'container-action', blocker: null,
|
||||
command: { state: 'attempted', message: `Durable agent operation ${operation.id}; reconciliation always uses this ID.` } });
|
||||
})();
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { queueRebuildServer } from './instances.js';
|
||||
import { restartRequestInFlight } from './restarts.js';
|
||||
import { runtimeActions, type RuntimeAction } from '../../provisioning.js';
|
||||
import { recordProgress } from './progress.js';
|
||||
import { nativeInstance } from '../backends/native.js';
|
||||
|
||||
export interface LifecycleOperation {
|
||||
id: string; server: string; action: string; state: string; stage: string;
|
||||
@@ -56,9 +57,10 @@ export function requestRestart(server: string, source: RestartSource): Lifecycle
|
||||
// stale file preconditions after a beta; its failure must not strand
|
||||
// the rollback that would restore the server.
|
||||
const rollback = source.kind === 'campaign' && !!db().prepare("SELECT 1 FROM deployments WHERE id = ? AND action = 'rollback'").get(source.id);
|
||||
if (source.kind === 'campaign' && !row.restarted_at && !rollback) {
|
||||
if (source.kind === 'campaign' && !row.restarted_at && !rollback && !nativeInstance(server)) {
|
||||
id = (db().prepare(`SELECT d.id FROM server_deployments d WHERE d.server = ? AND d.server_deleted_at IS NULL AND d.action = 'restart'
|
||||
AND d.state = 'waiting' AND d.submitted_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM native_provisioning n WHERE n.deployment_id=d.id AND n.stage_operation_id IS NOT NULL)
|
||||
AND EXISTS (SELECT 1 FROM provisioning_sources s WHERE s.operation_id = d.id AND s.kind = 'campaign')
|
||||
AND NOT EXISTS (SELECT 1 FROM provisioning_sources s WHERE s.operation_id = d.id AND s.kind != 'campaign')
|
||||
AND NOT EXISTS (SELECT 1 FROM provisioning_sources s JOIN deployments c ON c.id = s.source_id
|
||||
@@ -135,7 +137,7 @@ export function releaseRestartRequest(server: string, source: RestartSource): vo
|
||||
|
||||
export function requestLifecycle(server: string, action: RuntimeAction) {
|
||||
if (!runtimeActions.includes(action)) return { ok: false as const, status: 400, error: 'Choose start, restart, rebuild, or stop.' };
|
||||
if (!komodoConfigured() || !rconConfigured()) return { ok: false as const, status: 503, error: 'Configure Komodo and RCON before provisioning.' };
|
||||
if (!nativeInstance(server) && (!komodoConfigured() || !rconConfigured())) return { ok: false as const, status: 503, error: 'Configure a host and RCON before provisioning.' };
|
||||
return db().transaction(() => {
|
||||
const blocker = action === 'rebuild' ? activeBetaRemovalBlocker(server) : null;
|
||||
if (blocker) return { ok: false as const, status: 409, error: blocker };
|
||||
@@ -184,7 +186,7 @@ function watchdogRequestGuard(server: string, incidentId: string): Extract<Lifec
|
||||
const row = getServer(server);
|
||||
if (!row || row.destroyed_at) return { ok: false, status: 404, error: 'Server not found.' };
|
||||
if (row.desired_state === 'stopped') return { ok: false, status: 409, error: 'This server is intentionally stopped.' };
|
||||
if (!komodoConfigured()) return { ok: false, status: 503, error: 'Configure Komodo before provisioning.' };
|
||||
if (!nativeInstance(server) && !komodoConfigured()) return { ok: false, status: 503, error: 'Configure a host before provisioning.' };
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { applyStagedFilesForOperation } from '../fleet/staged-files.js';
|
||||
import type { RuntimeAction } from '../../provisioning.js';
|
||||
import type { ProvisioningBlocker } from '../../provisioning-detail.js';
|
||||
import { recordProgress, type ProgressUpdate } from './progress.js';
|
||||
import { nativeDeployment } from '../backends/native.js';
|
||||
|
||||
// Komodo queues restart work. Don't mistake the still-running old container
|
||||
// for a completed restart when an API kick immediately follows the request.
|
||||
@@ -166,6 +167,7 @@ async function refresh(): Promise<void> {
|
||||
const slots = db().prepare('SELECT * FROM restart_rollout WHERE healthy_at IS NULL ORDER BY requested_at, server')
|
||||
.all() as RestartSlot[];
|
||||
for (const slot of slots) {
|
||||
if (slot.operation_id && nativeDeployment(slot.operation_id)) continue;
|
||||
if (requestsInFlight.has(slot.server)) continue;
|
||||
// Recovery capacity transferred to a rebuild remains reserved until
|
||||
// teardown. Its health is verified by the creation worker, never here.
|
||||
|
||||
@@ -12,6 +12,8 @@ import { cancelProvisioning } from './requests.js';
|
||||
import type { RuntimeAction } from '../../provisioning.js';
|
||||
import type { ProvisioningBlocker } from '../../provisioning-detail.js';
|
||||
import { recordProgress } from './progress.js';
|
||||
import { nativeInstance, nativeDeployment } from '../backends/native.js';
|
||||
import { processNativeProvisioning } from './native.js';
|
||||
|
||||
const stoppedStates = new Set(['stopped', 'exited', 'created', 'dead']);
|
||||
export const isStopped = (state?: string) => stoppedStates.has(state?.toLowerCase() ?? '');
|
||||
@@ -98,6 +100,19 @@ export function processProvisioning(): Promise<void> {
|
||||
export const processLifecycle = processProvisioning;
|
||||
|
||||
async function run(): Promise<void> {
|
||||
await processNativeProvisioning(operation => {
|
||||
if (operation.action === 'create') return null;
|
||||
if (operation.action === 'destroy') {
|
||||
const other = db().prepare(`SELECT id FROM server_deployments WHERE server=? AND id!=? AND server_deleted_at IS NULL
|
||||
AND (state='running' OR (state='awaiting-health' AND action NOT IN ('create','rebuild'))) LIMIT 1`).get(operation.server, operation.id);
|
||||
return other ? 'Waiting for the current server operation.' : null;
|
||||
}
|
||||
const typed = operation as Operation;
|
||||
const eligibility = sourceEligibility(typed);
|
||||
if (eligibility === 'cancelled') { cancelProvisioning(operation.id, 'Requester cancelled or replaced this operation'); return 'Requester cancelled this operation.'; }
|
||||
if (eligibility === 'held') return 'Waiting for the requester to resume.';
|
||||
return blocker(operation.server, operation.id, typed.action)?.message ?? null;
|
||||
});
|
||||
await processCreations();
|
||||
await processDestructions();
|
||||
await refreshRestartRollout();
|
||||
@@ -105,6 +120,7 @@ async function run(): Promise<void> {
|
||||
WHERE server_deleted_at IS NULL AND action IN ('start','restart','stop','rebuild') AND (state = 'waiting' OR (action = 'stop' AND state = 'awaiting-health'))
|
||||
ORDER BY started_at, rowid`).all() as Operation[];
|
||||
for (const operation of operations) {
|
||||
if (nativeDeployment(operation.id)) continue;
|
||||
try { await processOperation(operation); }
|
||||
catch (err) {
|
||||
// Observation failures are retried; remote mutations are never replayed.
|
||||
|
||||
@@ -6,12 +6,19 @@
|
||||
import Button from './primitives/Button.svelte';
|
||||
import LaunchRecipe from './LaunchRecipe.svelte';
|
||||
import ServerIdentity from './ServerIdentity.svelte';
|
||||
import type { CollectionMode, CollectionPreview } from '$lib/deployment-plan.js';
|
||||
import NativeHostSelection from './NativeHostSelection.svelte';
|
||||
import type { NativeDeploymentSelection } from '$lib/hosts.js';
|
||||
import type { ZombieGame } from '$lib/map-aliases.js';
|
||||
import type { BlueprintSummary, CollectionMode, CollectionPreview } from '$lib/deployment-plan.js';
|
||||
|
||||
let { open = $bindable(false), blueprintId = '' }: { open?: boolean; blueprintId?: string } = $props();
|
||||
let dialog: HTMLDialogElement | undefined = $state();
|
||||
let preview = $state<CollectionPreview | null>(null);
|
||||
let mode = $state<CollectionMode>('fill');
|
||||
let games = $state<ZombieGame[]>([]);
|
||||
let collectionName = $state('Launch collection');
|
||||
let hostSelection = $state<NativeDeploymentSelection>({ hostId: '', libraryIds: {}, image: 'ghcr.io/ayymoss/plutainer:latest' });
|
||||
let hostReady = $state(false);
|
||||
let confirmRemovals = $state(false);
|
||||
let keys = $state<Record<string, string>>({});
|
||||
let busy = $state(false);
|
||||
@@ -28,16 +35,31 @@
|
||||
|
||||
$effect(() => {
|
||||
if (!dialog) return;
|
||||
if (open && !wasOpen) { wasOpen = true; mode = 'fill'; keys = {}; preview = null; keyNotice = ''; dialog.showModal(); void review(); }
|
||||
if (open && !wasOpen) { wasOpen = true; mode = 'fill'; keys = {}; preview = null; keyNotice = ''; dialog.showModal(); void initialize(); }
|
||||
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
|
||||
});
|
||||
|
||||
async function initialize() {
|
||||
const current = ++session; busy = true; error = ''; games = []; hostReady = false;
|
||||
hostSelection = { hostId: '', libraryIds: {}, image: 'ghcr.io/ayymoss/plutainer:latest' };
|
||||
try {
|
||||
const response = await fetch('/api/blueprints'); const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not load collection.');
|
||||
if (current !== session) return;
|
||||
const blueprint = (body.blueprints as BlueprintSummary[]).find(item => item.id === blueprintId);
|
||||
if (!blueprint) throw new Error('This collection is no longer available.');
|
||||
games = [...new Set(blueprint.entries.map(entry => entry.game))]; collectionName = blueprint.name;
|
||||
} catch (cause) { if (current === session) error = (cause as Error).message; }
|
||||
finally { if (current === session) busy = false; }
|
||||
}
|
||||
|
||||
async function review() {
|
||||
if (!hostReady) { error = 'Choose a ready host and cached game files for each game in this collection.'; return; }
|
||||
const current = ++session;
|
||||
const previous = preview;
|
||||
busy = true; error = ''; keyNotice = ''; confirmRemovals = false;
|
||||
try {
|
||||
const res = await fetch('/api/deployment-collections/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprintId, mode }) });
|
||||
const res = await fetch('/api/deployment-collections/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprintId, mode, hostId: hostSelection.hostId, libraryIds: hostSelection.libraryIds, image: hostSelection.image }) });
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not preview the collection.');
|
||||
if (current === session) {
|
||||
@@ -73,16 +95,17 @@
|
||||
|
||||
<dialog bind:this={dialog} aria-label="Launch collection" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<div class="composer">
|
||||
<header><div><span class="eyebrow">Collection launch</span><h2>{preview?.blueprintName ?? 'Preparing your collection'}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close collection preview">×</button></header>
|
||||
<header><div><span class="eyebrow">Collection launch</span><h2>{preview?.blueprintName ?? collectionName}</h2></div><button class="close" disabled={busy} onclick={() => open = false} aria-label="Close collection preview">×</button></header>
|
||||
<label class="mode">Apply collection
|
||||
<select bind:value={mode} disabled={busy} onchange={event => { mode = event.currentTarget.value as CollectionMode; void review(); }}>
|
||||
<select bind:value={mode} disabled={busy} onchange={event => { mode = event.currentTarget.value as CollectionMode; preview = null; confirmRemovals = false; }}>
|
||||
<option value="fill">Fill missing servers</option>
|
||||
<option value="exact">Match collection exactly</option>
|
||||
</select>
|
||||
</label>
|
||||
{#if games.length}<NativeHostSelection {games} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} onchange={() => { preview = null; confirmRemovals = false; }} />{/if}
|
||||
<p class="muted">{mode === 'exact' ? 'Keep matching lobbies, add missing ones, and remove excess lobbies and maps outside this collection across both games.' : 'Create missing lobbies and keep all existing servers.'}</p>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
{#if !preview}<p class="muted">{busy ? 'Finding a lobby and port for every server…' : 'Refresh to prepare another preview.'}</p>{#if !busy}<Button onclick={review}>Refresh preview</Button>{/if}
|
||||
{#if !preview}<p class="muted">{busy ? 'Preparing the collection…' : 'Select a host and game files, then review capacity and server keys.'}</p>{#if !busy}<Button disabled={!hostReady} onclick={review}>Preview launch</Button>{/if}
|
||||
{:else}
|
||||
{#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if}
|
||||
{#if expired}<p class="error" role="status">This preview expired. Refresh before launch; keys remain entered for unchanged IDs.</p>{/if}
|
||||
@@ -122,7 +145,7 @@
|
||||
<div class="launch-footer"><div>{#if preview.servers.length}<p role="status"><strong>{keysReady} / {preview.servers.length} keys available</strong></p>{/if}<p class="caption">Key validity is checked at startup. Preview expires at {$timeFormat.minuteTime(preview.expiresAt)}.</p>{#if keysReady < preview.servers.length}<button class="text-button" type="button" onclick={nextMissing}>Jump to next missing key</button>{/if}</div><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant={preview.removals.length ? 'danger' : 'primary'} loading={busy} disabled={busy || expired || !canLaunch}>{preview.mode === 'exact' ? `Apply: ${preview.servers.length} create, ${preview.removals.length} remove` : `Queue ${preview.servers.length} server${preview.servers.length === 1 ? '' : 's'} →`}</Button></div></div>
|
||||
</form>
|
||||
{:else}<p class="muted">{preview.mode === 'exact' ? 'Your fleet already matches this collection.' : 'Your fleet already meets or exceeds every target in this collection.'} No servers will be created or removed.</p><div class="actions"><Button onclick={() => open = false}>Done</Button></div>{/if}
|
||||
<details class="recipes"><summary>Setup and launch requirements</summary><p class="muted requirements">Each server uses the latest promoted setup when its turn begins. The host must have the selected games and DLC. This preview checks settings and capacity; it does not probe game files, images, credentials, or network access.</p>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
|
||||
<details class="recipes"><summary>Setup and launch requirements</summary><p class="muted requirements">Each new server uses the selected host, immutable game-file version, and prepared runtime. Host readiness, cached files, runtime availability, and local port reservations are checked again before deployment.</p>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
|
||||
{/if}
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import Button from './primitives/Button.svelte';
|
||||
import LaunchRecipe from './LaunchRecipe.svelte';
|
||||
import DeployCollectionDialog from './DeployCollectionDialog.svelte';
|
||||
import NativeHostSelection from './NativeHostSelection.svelte';
|
||||
import type { NativeDeploymentSelection } from '$lib/hosts.js';
|
||||
import type { BlueprintSummary, LaunchPreview } from '$lib/deployment-plan.js';
|
||||
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
|
||||
|
||||
@@ -21,6 +23,8 @@
|
||||
let maps = $derived(catalogs[game]);
|
||||
let selectedBlueprint = $derived(blueprints.find(b => b.id === blueprintId));
|
||||
let preview = $state<LaunchPreview | null>(null);
|
||||
let hostSelection = $state<NativeDeploymentSelection>({ hostId: '', libraryIds: {}, image: 'ghcr.io/ayymoss/plutainer:latest' });
|
||||
let hostReady = $state(false);
|
||||
let plutoKey = $state('');
|
||||
let busy = $state(false);
|
||||
let loaded = $state(false);
|
||||
@@ -50,6 +54,7 @@
|
||||
blueprintId = initialBlueprintId;
|
||||
game = initialGame;
|
||||
mapSlug = initialMapSlug;
|
||||
hostSelection = { hostId: '', libraryIds: {}, image: 'ghcr.io/ayymoss/plutainer:latest' }; hostReady = false;
|
||||
try {
|
||||
const [mapRes, blueprintRes] = await Promise.all([fetch('/api/maps'), fetch('/api/blueprints')]);
|
||||
if (!mapRes.ok || !blueprintRes.ok) throw new Error('Could not load maps and collections. Try again.');
|
||||
@@ -59,8 +64,13 @@
|
||||
blueprints = blueprintBody.blueprints;
|
||||
if (!maps.some(m => m.slug === mapSlug)) mapSlug = maps[0]?.slug ?? '';
|
||||
if (blueprintId && !blueprints.some(b => b.id === blueprintId)) throw new Error('This collection is no longer available. Close this dialog and choose another starting point.');
|
||||
if (sourceDeploymentId) {
|
||||
const source = await fetch(`/api/provisioning/${encodeURIComponent(sourceDeploymentId)}`).then(response => response.json());
|
||||
if (current !== session) return;
|
||||
if (!source.recipe) throw new Error('The original launch recipe is unavailable.');
|
||||
game = source.recipe.game; mapSlug = source.recipe.mapSlug;
|
||||
}
|
||||
loaded = true;
|
||||
if (sourceDeploymentId) await review();
|
||||
} catch (err) { if (current === session) error = (err as Error).message; }
|
||||
}
|
||||
|
||||
@@ -69,12 +79,15 @@
|
||||
if (!sourceDeploymentId && selectedBlueprint) {
|
||||
open = false; collectionOpen = true; return;
|
||||
}
|
||||
if (!hostReady) { error = 'Choose a ready host, cached game-file version, and prepared runtime.'; return; }
|
||||
const current = session;
|
||||
const previousName = preview?.name;
|
||||
busy = true; error = ''; savedName = ''; keyNotice = '';
|
||||
try {
|
||||
const res = await fetch('/api/provisioning/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : blueprintId ? { blueprintId } : { game, mapSlug }) });
|
||||
body: JSON.stringify({ ...(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : { game, mapSlug }),
|
||||
hostId: hostSelection.hostId, libraryId: hostSelection.libraryIds[game], image: hostSelection.image,
|
||||
bindAddress: hostSelection.bindAddress || undefined, publicAddress: hostSelection.publicAddress || undefined, publicPort: hostSelection.publicPort }) });
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not prepare launch preview.');
|
||||
if (current === session) {
|
||||
@@ -137,7 +150,7 @@
|
||||
{#if preview.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Saved key ready</p>
|
||||
{:else}<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder="Paste the key for this server ID" /></label>{/if}
|
||||
<p class="muted">{preview.hasSavedKey ? 'The saved key for this server ID will be reused automatically. Electric Cherry does not check whether Plutonium accepts it. Update it in Keys.' : 'Add the key here. It is saved for this exact server ID when you launch, including if provisioning fails.'} <a href={`/server-keys?server=${encodeURIComponent(preview.name ?? '')}`} target="_blank" rel="noopener">Manage saved keys in a new tab</a>; your launch stays open here.</p>
|
||||
<div class="launch-actions">{#if !sourceDeploymentId}<Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button>{/if}<Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
|
||||
<div class="launch-actions"><Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
|
||||
{#if !preview.hasSavedKey && !plutoKey.trim()}<p class="muted">Enter this server’s key to enable launch.</p>{/if}
|
||||
<p class="caption">Preview valid until {$timeFormat.minuteTime(preview.expiresAt)}. Lobby and port availability are checked again at launch.</p>
|
||||
</form>
|
||||
@@ -147,14 +160,15 @@
|
||||
{/if}
|
||||
</details>
|
||||
{:else if sourceDeploymentId}
|
||||
<p class="muted">{busy ? 'Preparing the current promoted setup…' : 'Review the latest promoted setup for this map with a fresh lobby and port.'}</p><Button disabled={busy} onclick={review}>Review launch</Button>
|
||||
<p class="muted">Choose a host for the latest promoted {game.toUpperCase()} setup, with a fresh lobby and port.</p><NativeHostSelection games={[game]} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} network /><Button disabled={busy || !hostReady} onclick={review}>Review launch</Button>
|
||||
{:else}
|
||||
<form onsubmit={event => { event.preventDefault(); void review(); }}>
|
||||
<label>Start with<select bind:value={blueprintId} disabled={busy}><option value="">Current promoted setup</option>{#each blueprints as blueprint}<option value={blueprint.id}>{blueprint.name} · {blueprint.totalServers} server{blueprint.totalServers === 1 ? '' : 's'}</option>{/each}</select></label>
|
||||
{#if selectedBlueprint}<section class="recipe-panel"><h3>{selectedBlueprint.totalServers} target server{selectedBlueprint.totalServers === 1 ? '' : 's'}</h3>{#each selectedBlueprint.entries as entry}<p>{entry.count}× {entry.mapName} · {entry.game.toUpperCase()}</p>{/each}<p class="muted">Review missing servers and their keys together. Every new server uses the latest promoted scripts, plugins, and configuration.</p>{#if selectedBlueprint.description}<p class="muted">{selectedBlueprint.description}</p>{/if}</section>
|
||||
{:else}<p class="muted">Pick a map to review its current promoted scripts, plugins, and configuration. You can save the map as a collection for future launches.</p><div class="fields"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label></div>{/if}
|
||||
{#if !selectedBlueprint}<NativeHostSelection games={[game]} bind:selection={hostSelection} bind:ready={hostReady} disabled={busy} network />{/if}
|
||||
<div class="promise"><strong>Know what you’re launching.</strong><p>Review the current promoted setup, available lobby, and launch prerequisites before creating your server.</p></div>
|
||||
<div class="launch-actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || (!blueprintId && !mapSlug)}>Preview launch →</Button></div>
|
||||
<div class="launch-actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || (!blueprintId && (!mapSlug || !hostReady))}>Preview launch →</Button></div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<form onsubmit={(event) => { event.preventDefault(); void destroy(); }}>
|
||||
<h2 id="destroy-title">Queue server removal?</h2>
|
||||
<p class="name">{name}</p>
|
||||
<p>This stops the container, deletes its Komodo deployment, and removes its server files and campaign details. Provisioning history and the saved server key are retained.</p>
|
||||
<p>This stops and removes the container, its writable server files, and campaign details. Shared game libraries, provisioning history, and the saved server key are retained.</p>
|
||||
<div class="notice">
|
||||
{#if players !== null && players > 0}<strong>{players} player{players === 1 ? '' : 's'} currently online.</strong>{/if}
|
||||
Removal waits until a live check confirms the server is empty. Follow progress or cancel the waiting request on the provisioning page.
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
{#each runtimeActions as value}<option value={value}>{actionLabels[value]}</option>{/each}
|
||||
<option value="destroy">Destroy</option>
|
||||
</select></label>
|
||||
<p>{action === 'destroy' ? 'Destroy the container, delete its Komodo deployment, and remove all server files and registration after the server is empty. Provisioning history and the key saved separately in Keys are retained.' : runtimeDescriptions[action]}</p>
|
||||
<p>{action === 'destroy' ? 'Destroy the container and remove its writable server files and registration after the server is empty. Shared game libraries, provisioning history, and the key saved separately in Keys are retained.' : runtimeDescriptions[action]}</p>
|
||||
{#if destructive}<label class="confirmation"><input type="checkbox" bind:checked={confirmed} disabled={busy} /> I understand that all files and data in this instance will be permanently deleted.</label>{/if}
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { Host, NativeDeploymentSelection } from '$lib/hosts.js';
|
||||
import type { GameLibrary } from '$lib/game-files.js';
|
||||
import type { ZombieGame } from '$lib/map-aliases.js';
|
||||
let { games, selection = $bindable(), ready = $bindable(false), disabled = false, network = false, onchange }:
|
||||
{ games: ZombieGame[]; selection: NativeDeploymentSelection; ready?: boolean; disabled?: boolean; network?: boolean; onchange?: () => void } = $props();
|
||||
let hosts = $state<Host[]>([]); let libraries = $state<GameLibrary[]>([]); let loading = $state(true); let error = $state('');
|
||||
const host = $derived(hosts.find(item => item.id === selection.hostId));
|
||||
const versions = $derived(libraries.flatMap(library => library.versions.filter(version => version.state === 'ready').map(version => ({ ...version, name: library.name }))));
|
||||
const imageReady = $derived(!!host?.inventory.runtimeImages?.includes(selection.image));
|
||||
function cacheReady(versionId: string | undefined) { return !!versionId && versions.some(version => version.id === versionId && version.caches.some(cache => cache.hostId === selection.hostId && ['ready', 'outdated'].includes(cache.status))); }
|
||||
$effect(() => { ready = !loading && host?.readiness === 'ready' && imageReady && games.length > 0 && games.every(game => cacheReady(selection.libraryIds[game])); });
|
||||
async function load() {
|
||||
loading = true; error = '';
|
||||
try {
|
||||
const [hostResponse, libraryResponse] = await Promise.all([fetch('/api/hosts'), fetch('/api/game-files')]);
|
||||
if (!hostResponse.ok || !libraryResponse.ok) throw new Error('Could not load hosts and game libraries.');
|
||||
const [hostBody, libraryBody] = await Promise.all([hostResponse.json(), libraryResponse.json()]); hosts = hostBody.hosts; libraries = libraryBody.libraries;
|
||||
} catch (cause) { error = (cause as Error).message; } finally { loading = false; }
|
||||
}
|
||||
onMount(() => { void load(); });
|
||||
</script>
|
||||
|
||||
<section class="selection" aria-label="Deployment host and game files">
|
||||
<div class="title"><h3>Host and game files</h3><button type="button" disabled={disabled || loading} onclick={() => { onchange?.(); void load(); }}>Refresh hosts</button></div>
|
||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||
{#if loading}<p role="status">Loading host readiness and libraries…</p>{/if}
|
||||
<label>Game host<select bind:value={selection.hostId} disabled={disabled || loading} onchange={() => onchange?.()}><option value="">Choose a host</option>{#each hosts as candidate}<option value={candidate.id} disabled={candidate.readiness !== 'ready'}>{candidate.name} · {candidate.connectionState} · {candidate.readiness}</option>{/each}</select></label>
|
||||
{#if !loading && !hosts.some(candidate => candidate.readiness === 'ready')}<p>No deployment-ready hosts. <a href="/hosts" target="_blank" rel="noopener">Set up a host</a>, then refresh.</p>{/if}
|
||||
{#if host}<p>{host.hostname} · ports {host.portMin}–{host.portMax} · {host.publicAddress || host.bindAddress}</p>{/if}
|
||||
{#each games as game}<label>{game === 't5' ? 'Black Ops I' : 'Black Ops II'} game-file version<select bind:value={selection.libraryIds[game]} disabled={disabled || loading || !host} onchange={() => onchange?.()}><option value="">Choose an immutable library version</option>{#each versions.filter(version => version.game === game) as version}<option value={version.id} disabled={!cacheReady(version.id)}>{version.name} · {version.version} · {cacheReady(version.id) ? 'Ready on host' : 'Not cached on host'}</option>{/each}</select></label>{/each}
|
||||
<p><a href="/game-files" target="_blank" rel="noopener">Import or download game files to a host</a>, then refresh. Each launch stays pinned to its selected library version.</p>
|
||||
<label>Plutainer runtime image<input required bind:value={selection.image} disabled={disabled} onchange={() => onchange?.()} /></label>
|
||||
{#if host && !imageReady}<p class="error">Prepare this runtime image from <a href={`/hosts/${host.id}`} target="_blank" rel="noopener">host setup</a> before launch.</p>{/if}
|
||||
{#if network}<details><summary>Network overrides</summary><label>Local bind address<input bind:value={selection.bindAddress} placeholder={host?.bindAddress ?? 'Host default'} disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT address<input bind:value={selection.publicAddress} placeholder={host?.publicAddress || 'Host default'} disabled={disabled} onchange={() => onchange?.()} /></label><label>Public / NAT port<input type="number" min="1" max="65535" bind:value={selection.publicPort} placeholder="Same as local port" disabled={disabled} onchange={() => onchange?.()} /></label><p>Public mapping does not change the local Docker port reservation.</p></details>{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.selection { display:grid; gap:14px; padding:20px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface-sunken); } .title { display:flex; gap:12px; justify-content:space-between; align-items:center; } h3 { font-size:var(--text-sm); margin:0; } label { display:grid; gap:7px; font-size:13px; } input,select { width:100%; box-sizing:border-box; padding:11px; color:var(--text-primary); background:var(--surface); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; } p { color:var(--text-secondary); font-size:12px; line-height:1.6; margin:0; } a,button { color:var(--accent-primary-hover); } button { background:none; border:0; cursor:pointer; font:inherit; font-size:12px; } button:disabled { opacity:.5; cursor:default; } .error { color:var(--state-danger); } summary { cursor:pointer; font-size:13px; } details label { margin-top:14px; } details p { margin-top:12px; }
|
||||
</style>
|
||||
@@ -9,7 +9,7 @@
|
||||
const progress = $derived(detail.progress);
|
||||
let expanded = $state<Record<string, boolean>>({});
|
||||
const evidenceLabels = { pass: 'Passed', waiting: 'Waiting', fail: 'Failed', unknown: 'Unknown' };
|
||||
const sources = { komodo: 'Komodo', rcon: 'RCON', telemetry: 'Telemetry', combined: 'RCON and telemetry', manifest: 'Plutonium manifest' };
|
||||
const sources = { komodo: 'Komodo', agent: 'Host agent', rcon: 'RCON', telemetry: 'Telemetry', combined: 'RCON and telemetry', manifest: 'Plutonium manifest' };
|
||||
</script>
|
||||
|
||||
<section class="execution" aria-labelledby="execution-title">
|
||||
|
||||
@@ -9,7 +9,7 @@ export const fleet = writable<{ servers: FleetServer[]; operations: FleetOperati
|
||||
});
|
||||
export const fleetError = writable('');
|
||||
export const fleetRefreshing = writable(false);
|
||||
export const fleetIntegrations = writable<{ rcon: boolean; komodo: boolean; rconTimeoutMs: number; defaultRconHost: string } | null>(null);
|
||||
export const fleetIntegrations = writable<{ rcon: boolean; komodo: boolean; native?: boolean; hostsReady?: number; hostsTotal?: number; rconTimeoutMs: number; defaultRconHost: string } | null>(null);
|
||||
|
||||
export const fleetServers = derived([fleet, statusByServer, statusReceivedAt, matchEventsByServer, telemetryByServer, uiNow], ([snapshot, live, received, matches, reports, now]) =>
|
||||
snapshot.servers.map(server => {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { setupRequired } from '$lib/server/auth/index.js';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = ({ locals }) => ({ user: locals.user ?? null, setupRequired: setupRequired() });
|
||||
+74
-26
@@ -15,17 +15,24 @@
|
||||
import { resolveConfirmation } from '$ui/confirm.js';
|
||||
import '$ui/styles/components.css';
|
||||
import { enableViewTransitions } from '$ui/motion/viewTransition.js';
|
||||
import type { AuthUser } from '$lib/auth.js';
|
||||
// Side-effect import — puts @fontsource CSS on Vite's module graph so
|
||||
// the referenced woff/woff2 files get emitted into the build.
|
||||
import '$ui/styles/fonts.js';
|
||||
|
||||
interface Props { children: import('svelte').Snippet }
|
||||
let { children }: Props = $props();
|
||||
interface Props { children: import('svelte').Snippet; data: { user: AuthUser | null; setupRequired: boolean } }
|
||||
let { children, data }: Props = $props();
|
||||
const publicPage = $derived(['/login', '/setup'].includes($page.url.pathname));
|
||||
const sessionReady = $derived(!!data.user && !data.user.mustChangePassword && !publicPage);
|
||||
let menuOpen = $state(false);
|
||||
let accountOpen = $state(false);
|
||||
let signingOut = $state(false);
|
||||
let signOutError = $state('');
|
||||
let notificationsOpen = $state(false);
|
||||
let helpOpen = $state(false);
|
||||
const unread = $derived($notificationHistory.filter(item => !item.read).length);
|
||||
let menuButton: HTMLButtonElement;
|
||||
let menuButton = $state<HTMLButtonElement>();
|
||||
let accountButton = $state<HTMLButtonElement>();
|
||||
|
||||
function openUtility(panel: 'notifications' | 'help') {
|
||||
if (menuOpen) { menuButton?.focus(); menuOpen = false; }
|
||||
@@ -33,32 +40,51 @@
|
||||
else helpOpen = true;
|
||||
}
|
||||
|
||||
afterNavigate(() => { menuOpen = false; resolveConfirmation(false); });
|
||||
afterNavigate(() => { menuOpen = false; accountOpen = false; resolveConfirmation(false); });
|
||||
|
||||
function closeMenu(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && accountOpen) { accountOpen = false; accountButton?.focus(); }
|
||||
if (event.key === 'Escape' && menuOpen) {
|
||||
menuOpen = false;
|
||||
menuButton?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const stopTimeZone = startTimeZonePreference();
|
||||
async function signOut() {
|
||||
if (signingOut) return;
|
||||
signingOut = true; signOutError = '';
|
||||
try {
|
||||
const response = await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||||
if (!response.ok) { const body = await response.json(); throw new Error(body.error ?? 'Could not sign out. Try again.'); }
|
||||
try { sessionStorage.removeItem('electric-cherry:notifications'); } catch { /* Optional local history. */ }
|
||||
notificationHistory.set([]);
|
||||
window.location.assign('/login');
|
||||
} catch (cause) { signOutError = (cause as Error).message; signingOut = false; }
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!sessionReady) return;
|
||||
const stopNotifications = startNotificationHistory();
|
||||
const stopPipeline = startPipelineStream();
|
||||
const stopServers = startServerStream();
|
||||
const stopFleetMonitor = startFleetMonitor();
|
||||
const stopFleet = startFleetMonitor();
|
||||
return () => { stopNotifications(); stopFleet(); stopPipeline?.(); stopServers?.(); };
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const stopTimeZone = startTimeZonePreference();
|
||||
|
||||
// Two-key nav shortcuts (Gmail-style): `g f` fleet, `g d` provisioning,
|
||||
// `g b` betas, `g s` scripts. Ignored while typing in inputs.
|
||||
const shortcuts: Record<string, string> = {
|
||||
f: '/', d: '/provisioning', a: '/campaigns', w: '/maintenance', b: '/betas',
|
||||
k: '/server-keys', s: '/scripts', c: '/configs', p: '/plugins', l: '/leaderboard', h: '/settings', m: '/metrics'
|
||||
k: '/server-keys', s: '/scripts', c: '/configs', p: '/plugins', l: '/leaderboard', h: '/settings', m: '/metrics', o: '/hosts'
|
||||
};
|
||||
let armed = false;
|
||||
let armTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!sessionReady) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable || target.closest('dialog'))) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
@@ -74,6 +100,7 @@
|
||||
armed = false;
|
||||
if (armTimer) { clearTimeout(armTimer); armTimer = null; }
|
||||
const dest = shortcuts[e.key];
|
||||
if ((e.key === 'h' || e.key === 'k') && data.user?.role !== 'admin') return;
|
||||
if (dest) {
|
||||
e.preventDefault();
|
||||
goto(dest);
|
||||
@@ -83,10 +110,6 @@
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
stopTimeZone();
|
||||
stopNotifications();
|
||||
stopFleetMonitor();
|
||||
stopPipeline?.();
|
||||
stopServers?.();
|
||||
window.removeEventListener('keydown', onKey);
|
||||
if (armTimer) clearTimeout(armTimer);
|
||||
};
|
||||
@@ -98,7 +121,8 @@
|
||||
|
||||
const tabs = [
|
||||
{ href: '/', label: 'Fleet', match: (p: string) => p === '/' || p.startsWith('/servers/') },
|
||||
{ href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins)(\/|$)/.test(p) },
|
||||
{ href: '/hosts', label: 'Hosts', match: (p: string) => /^\/hosts(\/|$)/.test(p) },
|
||||
{ href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins|game-files)(\/|$)/.test(p) },
|
||||
{ href: '/provisioning', label: 'Operations', match: (p: string) => /^\/(provisioning|deployments|collections|campaigns|maintenance|watchdog|server-keys)(\/|$)/.test(p) },
|
||||
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') },
|
||||
{ href: '/settings', label: 'Settings', match: (p: string) => p.startsWith('/settings') },
|
||||
@@ -106,29 +130,33 @@
|
||||
{ href: '/matches', label: 'Match History', match: (p: string) => p.startsWith('/matches') },
|
||||
{ href: '/metrics', label: 'Metrics', match: (p: string) => p === '/metrics' }
|
||||
];
|
||||
const secondary = $derived(tabs[1].match($page.url.pathname)
|
||||
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }]
|
||||
: tabs[2].match($page.url.pathname)
|
||||
? [{ href: '/provisioning', label: 'Provisioning' }, { href: '/collections', label: 'Collections' }, { href: '/campaigns', label: 'Campaigns' }, { href: '/maintenance', label: 'Maintenance' }, { href: '/server-keys', label: 'Keys' }]
|
||||
: tabs[4].match($page.url.pathname) ? [{ href: '/settings', label: 'Preferences' }] : []);
|
||||
const visibleTabs = $derived(tabs.filter(tab => tab.label !== 'Settings' || data.user?.role === 'admin'));
|
||||
const secondary = $derived(tabs.find(tab => tab.label === 'Library')!.match($page.url.pathname)
|
||||
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }, { href: '/game-files', label: 'Game Files' }]
|
||||
: tabs.find(tab => tab.label === 'Operations')!.match($page.url.pathname)
|
||||
? [{ href: '/provisioning', label: 'Provisioning' }, { href: '/collections', label: 'Collections' }, { href: '/campaigns', label: 'Campaigns' }, { href: '/maintenance', label: 'Maintenance' }, ...(data.user?.role === 'admin' ? [{ href: '/server-keys', label: 'Keys' }] : [])]
|
||||
: tabs.find(tab => tab.label === 'Settings')!.match($page.url.pathname) ? [{ href: '/settings', label: 'Preferences' }, ...(data.user?.role === 'admin' ? [{ href: '/settings/users', label: 'Users' }] : [])] : []);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={closeMenu} />
|
||||
|
||||
<div class="shell">
|
||||
<a class="skip-link" href="#main-content">Skip to content</a>
|
||||
{#if publicPage}
|
||||
<header class="auth-brand"><BrandMark size="md" /></header>
|
||||
{:else}
|
||||
<nav aria-label="Primary">
|
||||
<a class="brand-link" href="/" aria-label="Electric Cherry home">
|
||||
<a class="brand-link" href={sessionReady ? '/' : '/account'} aria-label="Electric Cherry home">
|
||||
<BrandMark size="sm" />
|
||||
</a>
|
||||
<button class="menu-toggle" bind:this={menuButton} type="button"
|
||||
{#if sessionReady}<button class="menu-toggle" bind:this={menuButton} type="button"
|
||||
aria-expanded={menuOpen} aria-controls="primary-links"
|
||||
onclick={() => { menuOpen = !menuOpen; }}>
|
||||
{menuOpen ? 'Close' : 'Menu'}
|
||||
<span aria-hidden="true">{menuOpen ? '×' : '☰'}</span>
|
||||
</button>
|
||||
<div class="tabs" id="primary-links" class:expanded={menuOpen}>
|
||||
{#each tabs as t (t.href)}
|
||||
{#each visibleTabs as t (t.href)}
|
||||
{@const on = t.match($page.url.pathname)}
|
||||
<a
|
||||
href={t.href}
|
||||
@@ -138,18 +166,25 @@
|
||||
<span class="lbl">{t.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>{/if}
|
||||
<div class="nav-tools" class:expanded={menuOpen} class:account-only={!sessionReady}>
|
||||
{#if sessionReady}<button class="utility" aria-label={`Recent notifications${unread ? `, ${unread} unread` : ''}`} onclick={() => openUtility('notifications')}>Activity{unread ? ` · ${unread}` : ''}</button><button class="utility" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onclick={() => openUtility('help')}>?</button>{/if}
|
||||
{#if data.user}<div class="account-menu"><button bind:this={accountButton} class="utility account-button" aria-expanded={accountOpen} aria-controls="account-links" onclick={() => accountOpen = !accountOpen}>{data.user.displayName || data.user.username}<span class="role">{data.user.role}</span></button>
|
||||
{#if accountOpen}<div class="account-links" id="account-links"><a href="/account">My account</a>{#if data.user.role === 'admin' && sessionReady}<a href="/settings/users">Manage users</a>{/if}<button disabled={signingOut} onclick={signOut}>{signingOut ? 'Signing out…' : 'Sign out'}</button>{#if signOutError}<p role="alert">{signOutError}</p>{/if}</div>{/if}
|
||||
</div>{/if}
|
||||
</div>
|
||||
<div class="nav-tools" class:expanded={menuOpen}><button class="utility" aria-label={`Recent notifications${unread ? `, ${unread} unread` : ''}`} onclick={() => openUtility('notifications')}>Activity{unread ? ` · ${unread}` : ''}</button><button class="utility" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onclick={() => openUtility('help')}>?</button></div>
|
||||
</nav>
|
||||
{#if secondary.length}<div class="subnav"><div role="navigation" aria-label="Section">{#each secondary as item}<a href={item.href} aria-current={$page.url.pathname.startsWith(item.href) ? 'page' : undefined}>{item.label}</a>{/each}</div></div>{/if}
|
||||
<main id="main-content" tabindex="-1">
|
||||
{#if sessionReady && secondary.length}<div class="subnav"><div role="navigation" aria-label="Section">{#each secondary as item}<a href={item.href} aria-current={item.href === '/settings' ? $page.url.pathname === item.href ? 'page' : undefined : $page.url.pathname.startsWith(item.href) ? 'page' : undefined}>{item.label}</a>{/each}</div></div>{/if}
|
||||
{/if}
|
||||
<main id="main-content" tabindex="-1" class:auth-main={publicPage}>
|
||||
{#if sessionReady && data.user?.role === 'viewer'}<aside class="viewer-note"><strong>Viewer access</strong><span>You can read fleet status and summaries. Operating servers or viewing raw configs, file changes, logs, and telemetry requires an operator account.</span></aside>{/if}
|
||||
{@render children()}
|
||||
</main>
|
||||
<Toaster />
|
||||
<Confirmation />
|
||||
<NotificationHistory bind:open={notificationsOpen} />
|
||||
<Dialog bind:open={helpOpen} title="Keyboard shortcuts" description="Press g, then the destination key. Shortcuts are inactive while typing or using a dialog.">
|
||||
<dl class="shortcuts">{#each [['g f','Fleet'],['g s','Scripts'],['g c','Configs'],['g p','Plugins'],['g d','Provisioning'],['g a','Campaigns'],['g w','Maintenance'],['g b','Betas'],['g h','Settings'],['g k','Keys'],['g l','Leaderboard'],['g m','Metrics'],['?','This help']] as [keys,label]}<div><dt><kbd>{keys}</kbd></dt><dd>{label}</dd></div>{/each}</dl>
|
||||
<dl class="shortcuts">{#each [['g f','Fleet'],['g o','Hosts'],['g s','Scripts'],['g c','Configs'],['g p','Plugins'],['g d','Provisioning'],['g a','Campaigns'],['g w','Maintenance'],['g b','Betas'],...(data.user?.role === 'admin' ? [['g h','Settings'],['g k','Keys']] : []),['g l','Leaderboard'],['g m','Metrics'],['?','This help']] as [keys,label]}<div><dt><kbd>{keys}</kbd></dt><dd>{label}</dd></div>{/each}</dl>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
@@ -193,6 +228,16 @@
|
||||
}
|
||||
|
||||
.shell { min-height: 100vh; }
|
||||
.auth-brand { display:flex; justify-content:center; padding:48px 20px 20px; }
|
||||
main.auth-main { max-width:520px; padding-top:16px; }
|
||||
.viewer-note { display:flex; gap:8px 16px; flex-wrap:wrap; padding:14px 18px; margin-bottom:24px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); font-size:var(--text-sm); color:var(--text-secondary); } .viewer-note strong { color:var(--text-primary); }
|
||||
.account-menu { position:relative; }
|
||||
.account-button { display:flex; align-items:center; gap:8px; max-width:220px; overflow:hidden; text-overflow:ellipsis; }
|
||||
.role { text-transform:capitalize; color:var(--text-tertiary); font-size:var(--text-xs); }
|
||||
.account-links { position:absolute; right:0; top:calc(100% + 8px); min-width:200px; z-index:30; display:grid; padding:8px; background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--radius-md); box-shadow:0 12px 32px #0005; }
|
||||
.account-links a,.account-links button { text-align:left; padding:10px 12px; color:var(--text-primary); background:none; border:0; border-radius:var(--radius-sm); font:inherit; font-size:var(--text-sm); cursor:pointer; text-decoration:none; }
|
||||
.account-links a:hover,.account-links button:hover { background:var(--surface-raised); }
|
||||
.account-links p { padding:0 12px; font-size:var(--text-xs); color:var(--state-danger); }
|
||||
|
||||
nav {
|
||||
position: sticky;
|
||||
@@ -272,7 +317,7 @@
|
||||
}
|
||||
|
||||
/* The full navigation needs more room than a phone or portrait tablet. */
|
||||
@media (max-width: 1200px) {
|
||||
@media (max-width: 1500px) {
|
||||
nav {
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
@@ -303,6 +348,9 @@
|
||||
.tabs a { display: flex; align-items: center; min-height: 44px; border-radius: var(--radius-md); }
|
||||
.nav-tools { display: none; }
|
||||
.nav-tools.expanded { display: flex; width: 100%; padding-top: 8px; }
|
||||
.nav-tools.account-only { display:flex; }
|
||||
.account-menu { margin-left:auto; }
|
||||
.account-links { position:static; margin-top:8px; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
main { padding: var(--space-6) max(var(--space-4), env(safe-area-inset-right)) calc(var(--space-16) + env(safe-area-inset-bottom)) max(var(--space-4), env(safe-area-inset-left)); }
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
let deployOpen = $state(false);
|
||||
let initialGame = $state<ZombieGame>('t5');
|
||||
let initialMapSlug = $state('');
|
||||
const canOperate = $derived($page.data.user?.role === 'admin' || $page.data.user?.role === 'operator');
|
||||
const filters: { id: FleetFilter; label: string }[] = [
|
||||
{ id: 'all', label: 'All' }, { id: 'occupied', label: 'Occupied' }, { id: 'empty', label: 'Empty' },
|
||||
{ id: 'attention', label: 'Needs attention' }, { id: 'testing', label: 'Testing' }, { id: 'pinned', label: 'Pinned' }
|
||||
@@ -128,10 +129,10 @@
|
||||
<SectionHeader title="Fleet" subtitle="Live health, players, and server operations.">
|
||||
{#snippet actions()}
|
||||
<Button variant="ghost" size="sm" loading={$fleetRefreshing} onclick={() => void refreshFleet()}>Refresh</Button>
|
||||
<Button variant="primary" onclick={() => launch()}>Create server</Button>
|
||||
{#if canOperate}<Button variant="primary" onclick={() => launch()}>Create server</Button>{/if}
|
||||
{/snippet}
|
||||
</SectionHeader>
|
||||
<DeployServerDialog bind:open={deployOpen} {initialGame} {initialMapSlug} />
|
||||
{#if canOperate}<DeployServerDialog bind:open={deployOpen} {initialGame} {initialMapSlug} />{/if}
|
||||
|
||||
{#if $fleetError}
|
||||
<div class="error" role="alert"><strong>{$fleet.receivedAt ? 'Showing the last available snapshot.' : 'Fleet is unavailable.'}</strong> {$fleetError}
|
||||
@@ -148,7 +149,9 @@
|
||||
<a class="metric-link" class:no-campaigns={!campaigns} href="/campaigns"><strong>{campaigns}</strong> active campaigns</a>
|
||||
<span class="freshness">{$fleetError ? 'Reconnecting' : 'Updated'} · {snapshotAge < 60 ? `${snapshotAge}s` : `${Math.floor(snapshotAge / 60)}m`} ago</span>
|
||||
</div>{/if}
|
||||
{#if $fleetIntegrations && (!$fleetIntegrations.komodo || !$fleetIntegrations.rcon)}
|
||||
{#if $fleetIntegrations?.native && !$fleetIntegrations.hostsReady}
|
||||
<div class="setup-note"><span><strong>Prepare a game host</strong> · {$fleetIntegrations.hostsTotal ? 'Hosts are connected or enrolled but need setup before new deployments.' : 'Add a host, prepare its runtime, and download game files.'}</span><a href="/hosts">Review hosts →</a></div>
|
||||
{:else if $page.data.user?.role === 'admin' && $fleetIntegrations && !$fleetIntegrations.native && (!$fleetIntegrations.komodo || !$fleetIntegrations.rcon)}
|
||||
<div class="setup-note"><span><strong>Finish system setup</strong> · {!$fleetIntegrations.komodo ? 'Komodo' : ''}{!$fleetIntegrations.komodo && !$fleetIntegrations.rcon ? ' and ' : ''}{!$fleetIntegrations.rcon ? 'RCON' : ''} not configured.</span><a href="/settings">Review setup →</a></div>
|
||||
{/if}
|
||||
|
||||
@@ -176,7 +179,7 @@
|
||||
{#if servers.length === 0}
|
||||
<EmptyState title="No servers deployed">
|
||||
{#snippet actions()}
|
||||
<Button variant="primary" onclick={() => launch()}>Create a server</Button>
|
||||
{#if canOperate}<Button variant="primary" onclick={() => launch()}>Create a server</Button>{/if}
|
||||
<Button href="/collections" variant="ghost">Browse collections</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
@@ -205,7 +208,7 @@
|
||||
{#each groups as [key, group] (key)}
|
||||
<section class="map-group" aria-label={group.title}>
|
||||
{#if grouping !== 'none'}<div class="group-heading"><div>{#if grouping === 'map'}<span class="eyebrow">{group.game.toUpperCase()}</span>{/if}<h2>{group.title}</h2><p>{group.servers.length} {group.servers.length === 1 ? 'lobby' : 'lobbies'} · {group.servers.some(s => freshServer(s, $uiNow)) ? group.servers.reduce((sum, s) => sum + (freshServer(s, $uiNow) ? s.last_players ?? 0 : 0), 0) : '—'} players · {group.servers.filter(s => freshServer(s, $uiNow) && s.last_players === 0).length} empty</p></div>
|
||||
{#if group.launch}<Button size="sm" onclick={() => launch(group.launch)}>Add another lobby</Button>{/if}
|
||||
{#if canOperate && group.launch}<Button size="sm" onclick={() => launch(group.launch)}>Add another lobby</Button>{/if}
|
||||
</div>{/if}
|
||||
{#if view === 'cards'}
|
||||
<div class="cards">{#each group.servers as server (server.name)}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
let currentPassword = $state(''); let newPassword = $state(''); let confirmPassword = $state('');
|
||||
let busy = $state(false); let signingOut = $state(false); let error = $state(''); let notice = $state('');
|
||||
const user = $derived($page.data.user);
|
||||
async function changePassword(event: SubmitEvent) {
|
||||
event.preventDefault(); if (busy || signingOut) return; error = ''; notice = '';
|
||||
if (newPassword !== confirmPassword) { error = 'The new passwords do not match.'; return; }
|
||||
const required = user?.mustChangePassword;
|
||||
busy = true;
|
||||
try {
|
||||
const response = await fetch('/api/auth/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword, newPassword }) });
|
||||
const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not change your password.');
|
||||
currentPassword = ''; newPassword = ''; confirmPassword = '';
|
||||
if (required) { window.location.assign('/'); return; }
|
||||
await invalidateAll(); notice = 'Your password has been changed.';
|
||||
} catch (cause) { error = (cause as Error).message; } finally { busy = false; }
|
||||
}
|
||||
async function logout() {
|
||||
if (busy || signingOut) return; signingOut = true; error = '';
|
||||
try {
|
||||
const response = await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||||
if (!response.ok) { const body = await response.json(); throw new Error(body.error ?? 'Could not sign out.'); }
|
||||
try { sessionStorage.removeItem('electric-cherry:notifications'); } catch { /* Optional history. */ }
|
||||
window.location.assign('/login');
|
||||
} catch (cause) { error = (cause as Error).message; signingOut = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>My account · Electric Cherry</title></svelte:head>
|
||||
<section class="page"><SectionHeader title="My account" subtitle="Manage your password and current session." />
|
||||
{#if user}
|
||||
<section class="panel identity"><div><h2>{user.displayName}</h2><p>@{user.username} · <span class="role">{user.role}</span></p></div><Button disabled={busy || signingOut} loading={signingOut} onclick={logout}>Sign out</Button></section>
|
||||
{#if user.mustChangePassword}<p class="required" role="status">Your account uses a temporary password. Choose a new password before continuing to Electric Cherry.</p>{/if}
|
||||
<section class="panel"><h2>{user.mustChangePassword ? 'Choose your password' : 'Change password'}</h2>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}{#if notice}<p class="notice" role="status">{notice}</p>{/if}
|
||||
<form onsubmit={changePassword} aria-busy={busy}><fieldset disabled={busy || signingOut}>
|
||||
<input type="text" class="password-username" value={user.username} autocomplete="username" aria-label="Username" readonly tabindex="-1" />
|
||||
<label for="current-password">{user.mustChangePassword ? 'Temporary password' : 'Current password'}<input id="current-password" name="currentPassword" type="password" autocomplete="current-password" required maxlength="128" bind:value={currentPassword} /></label>
|
||||
<label for="new-password">New password<input id="new-password" name="newPassword" type="password" autocomplete="new-password" required minlength="15" maxlength="128" bind:value={newPassword} aria-describedby="new-password-help" /></label>
|
||||
<p id="new-password-help" class="hint">Use 15–128 characters and a password you do not use elsewhere.</p>
|
||||
<label for="confirm-password">Confirm new password<input id="confirm-password" name="confirmPassword" type="password" autocomplete="new-password" required minlength="15" maxlength="128" bind:value={confirmPassword} /></label>
|
||||
<div><Button type="submit" variant="primary" loading={busy} disabled={busy || signingOut}>{user.mustChangePassword ? 'Save password and continue' : 'Change password'}</Button></div>
|
||||
</fieldset></form>
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.page { max-width:760px; display:grid; gap:24px; } .panel { padding:24px; background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--radius-md); } h2 { font-size:var(--text-md); margin:0; } .identity { display:flex; align-items:center; justify-content:space-between; gap:20px; } .identity p { margin:8px 0 0; color:var(--text-secondary); font-size:var(--text-sm); } .role { text-transform:capitalize; }
|
||||
.required { padding:16px; margin:0; color:var(--text-primary); background:var(--surface-raised); border-left:3px solid var(--accent-primary); font-size:var(--text-sm); line-height:1.6; }
|
||||
form { margin-top:24px; max-width:460px; } fieldset { border:0; margin:0; padding:0; display:grid; gap:18px; min-width:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; padding:12px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-md); font:inherit; } .password-username { position:absolute; width:1px; height:1px; clip-path:inset(50%); overflow:hidden; padding:0; border:0; } .hint { margin:-8px 0 0; color:var(--text-secondary); font-size:var(--text-xs); line-height:1.6; } .error { color:var(--state-danger); font-size:var(--text-sm); } .notice { color:var(--state-online); font-size:var(--text-sm); }
|
||||
@media(max-width:540px) { .identity { align-items:flex-start; flex-direction:column; } }
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { rotateCredential } from '$lib/server/hosts/index.js';
|
||||
import { authenticateAgent, hostApiError, objectBody } from '$lib/server/hosts/http.js';
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const host = authenticateAgent(request);
|
||||
try {
|
||||
const body = await objectBody(request);
|
||||
if (typeof body.credential !== 'string') throw new Error('Persist a randomly generated replacement credential before requesting rotation.');
|
||||
rotateCredential(host.id, body.credential);
|
||||
return json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (cause) { return hostApiError(cause); }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { enrollHost } from '$lib/server/hosts/index.js';
|
||||
import { hostApiError, objectBody, requireAgentTransport } from '$lib/server/hosts/http.js';
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
requireAgentTransport(request);
|
||||
try { const body = await objectBody(request); return json(enrollHost(body.token, body.inventory), { headers: { 'Cache-Control': 'no-store' } }); }
|
||||
catch (cause) { return hostApiError(cause); }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { heartbeat } from '$lib/server/hosts/index.js';
|
||||
import { authenticateAgent, hostApiError, objectBody } from '$lib/server/hosts/http.js';
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const authenticated = authenticateAgent(request);
|
||||
try {
|
||||
const body = await objectBody(request);
|
||||
const host = heartbeat(authenticated.id, body.inventory);
|
||||
const inventory = body.inventory as Record<string, unknown>;
|
||||
if (Array.isArray(inventory.libraryCaches)) {
|
||||
const { updateHostLibraryCaches } = await import('$lib/server/game-files/index.js');
|
||||
updateHostLibraryCaches(host.id, inventory.libraryCaches, { snapshot: true });
|
||||
}
|
||||
return json({ host, heartbeatSeconds: 15, serverTime: new Date().toISOString() });
|
||||
} catch (cause) { return hostApiError(cause); }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authenticateAgent } from '$lib/server/hosts/http.js';
|
||||
import { requireImportOwner, setImportManifest } from '$lib/server/game-files/index.js';
|
||||
import { gameFileError, readGameJson } from '$lib/server/game-files/http.js';
|
||||
export function GET({ request, params }: { request: Request; params: { id: string } }) {
|
||||
try { return json(requireImportOwner(authenticateAgent(request).id, params.id)); }
|
||||
catch (cause) { return gameFileError(cause); }
|
||||
}
|
||||
export async function PUT({ request, params }: { request: Request; params: { id: string } }) {
|
||||
try {
|
||||
const host = authenticateAgent(request);
|
||||
const body = await readGameJson(request, 64 * 1024 * 1024);
|
||||
return json(setImportManifest(host.id, params.id, body.files));
|
||||
} catch (cause) { return gameFileError(cause); }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authenticateAgent } from '$lib/server/hosts/http.js';
|
||||
import { importPublicationStatus, requestImportPublication } from '$lib/server/game-files/index.js';
|
||||
import { gameFileError } from '$lib/server/game-files/http.js';
|
||||
export function POST({ request, params }: { request: Request; params: { id: string } }) {
|
||||
try {
|
||||
const result = requestImportPublication(authenticateAgent(request).id, params.id);
|
||||
return json(result, { status: result.status === 'verifying' || result.status === 'pending' ? 202 : 200 });
|
||||
}
|
||||
catch (cause) { return gameFileError(cause); }
|
||||
}
|
||||
export function GET({ request, params }: { request: Request; params: { id: string } }) {
|
||||
try { return json(importPublicationStatus(authenticateAgent(request).id, params.id)); }
|
||||
catch (cause) { return gameFileError(cause); }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authenticateAgent } from '$lib/server/hosts/http.js';
|
||||
import { uploadImportFile } from '$lib/server/game-files/index.js';
|
||||
import { gameFileError } from '$lib/server/game-files/http.js';
|
||||
export async function PUT({ request, params }: { request: Request; params: { id: string; path: string } }) {
|
||||
try {
|
||||
const host = authenticateAgent(request);
|
||||
await uploadImportFile(host.id, params.id, params.path, request.body);
|
||||
return json({ uploaded: true });
|
||||
} catch (cause) { return gameFileError(cause); }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { agentReleaseAsset } from '$lib/server/hosts/releases.js';
|
||||
export async function GET() { return new Response(await agentReleaseAsset('install.sh'), { headers: { 'Content-Type': 'text/x-shellscript', 'Cache-Control': 'no-cache' } }); }
|
||||
@@ -0,0 +1,7 @@
|
||||
import { authenticateAgent } from '$lib/server/hosts/http.js';
|
||||
import { gameFileError, serveGameFile } from '$lib/server/game-files/http.js';
|
||||
export async function GET({ request, params }: { request: Request; params: { id: string; path: string } }) {
|
||||
try { authenticateAgent(request); return await serveGameFile(request, params.id, params.path); }
|
||||
catch (cause) { return gameFileError(cause); }
|
||||
}
|
||||
export const HEAD = GET;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authenticateAgent } from '$lib/server/hosts/http.js';
|
||||
import { getLibraryVersion } from '$lib/server/game-files/index.js';
|
||||
import { gameFileError } from '$lib/server/game-files/http.js';
|
||||
export function GET({ request, params }: { request: Request; params: { id: string } }) {
|
||||
try {
|
||||
authenticateAgent(request);
|
||||
const version = getLibraryVersion(params.id);
|
||||
if (!version || version.state !== 'ready') return json({ error: 'Published library not found.' }, { status: 404 });
|
||||
return json({ id: version.id, game: version.game, files: version.files, sizeBytes: version.sizeBytes, manifestSha256: version.manifestSha256 });
|
||||
} catch (cause) { return gameFileError(cause); }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authenticateAgent } from '$lib/server/hosts/http.js';
|
||||
import { updateHostLibraryCaches } from '$lib/server/game-files/index.js';
|
||||
import { gameFileError, readGameJson } from '$lib/server/game-files/http.js';
|
||||
export async function POST({ request, params }: { request: Request; params: { id: string } }) {
|
||||
try {
|
||||
const host = authenticateAgent(request); const body = await readGameJson(request);
|
||||
updateHostLibraryCaches(host.id, [{ ...body, libraryVersionId: params.id }]);
|
||||
return json({ ok: true });
|
||||
} catch (cause) { return gameFileError(cause); }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { pendingHostOperations } from '$lib/server/hosts/index.js';
|
||||
import { authenticateAgent } from '$lib/server/hosts/http.js';
|
||||
export function GET({ request }: { request: Request }) {
|
||||
const host = authenticateAgent(request);
|
||||
return json({ operations: pendingHostOperations(host.id), serverTime: new Date().toISOString() }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { reportHostOperation } from '$lib/server/hosts/index.js';
|
||||
import { authenticateAgent, hostApiError, objectBody } from '$lib/server/hosts/http.js';
|
||||
export async function POST({ request, params }: { request: Request; params: { id: string } }) {
|
||||
const host = authenticateAgent(request);
|
||||
try { const body = await objectBody(request, 128 * 1024 * 1024); const operation = reportHostOperation(host.id, params.id, { state: body.state, result: body.result, error: body.error }); return json({ operation: { id: operation.id, state: operation.state } }); }
|
||||
catch (cause) { return hostApiError(cause); }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { agentReleaseAsset } from '$lib/server/hosts/releases.js';
|
||||
export async function GET({ params }: { params: { file: string } }) {
|
||||
if (!['ec-agent.py','manifest.json'].includes(params.file)) error(404, 'Release asset not found.');
|
||||
return new Response(await agentReleaseAsset(params.file as 'ec-agent.py' | 'manifest.json'), { headers: { 'Content-Type': params.file.endsWith('.json') ? 'application/json' : 'text/plain', 'Cache-Control': 'no-cache' } });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authenticatePassword, revokeSession } from '$lib/server/auth/index.js';
|
||||
import { authBody, authError, sessionCookie, sessionResponse } from '$lib/server/auth/http.js';
|
||||
export const POST: RequestHandler = async ({ request, cookies, url, getClientAddress }) => {
|
||||
try {
|
||||
const result = await authenticatePassword(await authBody(request),getClientAddress());
|
||||
revokeSession(cookies.get(sessionCookie(url)));
|
||||
return sessionResponse(result,cookies,url);
|
||||
} catch (cause) { return authError(cause); }
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { recordAudit, revokeSession } from '$lib/server/auth/index.js';
|
||||
import { sessionCookie } from '$lib/server/auth/http.js';
|
||||
export const POST: RequestHandler = ({ cookies, url, locals }) => {
|
||||
revokeSession(cookies.get(sessionCookie(url)));
|
||||
cookies.delete(sessionCookie(url),{path:'/'});
|
||||
recordAudit(locals.user?.id??null,'logout',locals.user?.id??null);
|
||||
return json({ ok:true });
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
export const GET: RequestHandler = ({ locals }) => json({ user:locals.user },{headers:{'Cache-Control':'no-store'}});
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { changePassword, consumeAuthAttempt } from '$lib/server/auth/index.js';
|
||||
import { authBody, authError, requireUser, sessionResponse } from '$lib/server/auth/http.js';
|
||||
export const POST: RequestHandler = async ({ request, cookies, locals, url, getClientAddress }) => {
|
||||
try {
|
||||
const user = requireUser(locals.user);
|
||||
consumeAuthAttempt(getClientAddress(),user.id,'password');
|
||||
const body = await authBody(request);
|
||||
return sessionResponse(await changePassword(user.id,body.currentPassword,body.newPassword),cookies,url);
|
||||
} catch (cause) { return authError(cause); }
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { bootstrapAdmin } from '$lib/server/auth/index.js';
|
||||
import { authBody, authError, sessionResponse } from '$lib/server/auth/http.js';
|
||||
export const POST: RequestHandler = async ({ request, cookies, url, getClientAddress }) => {
|
||||
try { return sessionResponse(await bootstrapAdmin(await authBody(request),getClientAddress()),cookies,url); }
|
||||
catch (cause) { return authError(cause); }
|
||||
};
|
||||
@@ -4,6 +4,6 @@ import { previewCollection } from '$lib/server/fleet/collections.js';
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
return json(previewCollection(body?.blueprintId, body?.mode));
|
||||
return json(previewCollection(body?.blueprintId, body?.mode, body));
|
||||
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createLibraryImport, libraryOverview } from '$lib/server/game-files/index.js';
|
||||
import { gameFileError, readGameJson } from '$lib/server/game-files/http.js';
|
||||
import { requireManagementOrigin } from '$lib/server/hosts/http.js';
|
||||
|
||||
export function GET() { return json(libraryOverview()); }
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
try { requireManagementOrigin(request); return json({ version: createLibraryImport(await readGameJson(request)) }, { status: 202 }); }
|
||||
catch (cause) { return gameFileError(cause); }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getLibraryVersion } from '$lib/server/game-files/index.js';
|
||||
export function GET({ params }: { params: { id: string } }) {
|
||||
const version = getLibraryVersion(params.id);
|
||||
return version ? json({ version }) : json({ error: 'Library version not found.' }, { status: 404 });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { queueLibraryDownload } from '$lib/server/game-files/index.js';
|
||||
import { gameFileError, readGameJson } from '$lib/server/game-files/http.js';
|
||||
import { requireManagementOrigin } from '$lib/server/hosts/http.js';
|
||||
export async function POST({ request, params }: { request: Request; params: { id: string } }) {
|
||||
try {
|
||||
requireManagementOrigin(request);
|
||||
const body = await readGameJson(request);
|
||||
if (typeof body.hostId !== 'string') throw new Error('Choose a target host.');
|
||||
return json({ operation: queueLibraryDownload(body.hostId, params.id) }, { status: 202 });
|
||||
} catch (cause) { return gameFileError(cause); }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createEnrollment, createHost, listHosts } from '$lib/server/hosts/index.js';
|
||||
import { hostApiError, installerCommand, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
|
||||
|
||||
export function GET({ request }: { request: Request }) { requireManagementOrigin(request); return json({ hosts: listHosts() }); }
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
requireManagementOrigin(request);
|
||||
try {
|
||||
const host = createHost(await objectBody(request));
|
||||
const enrollment = createEnrollment(host.id);
|
||||
return json({ host, enrollment: { ...enrollment, command: installerCommand(request.url, enrollment.token) } }, { status: 201, headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (cause) { return hostApiError(cause); }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { getHost, listHostOperations, updateHost } from '$lib/server/hosts/index.js';
|
||||
import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
|
||||
type Event = { request: Request; params: { id: string } };
|
||||
export function GET({ request, params }: Event) {
|
||||
requireManagementOrigin(request); const host = getHost(params.id); if (!host) error(404, 'Host not found.');
|
||||
// Payloads and results can contain RCON keys or staged credentials. History
|
||||
// exposes metadata only; agent APIs remain the only command delivery path.
|
||||
return json({ host, operations: listHostOperations(host.id).map(({ payload: _payload, result: _result, ...operation }) => operation) });
|
||||
}
|
||||
export async function PATCH({ request, params }: Event) {
|
||||
requireManagementOrigin(request);
|
||||
try { return json({ host: updateHost(params.id, await objectBody(request)) }); } catch (cause) { return hostApiError(cause); }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { queueHostOperation, revokeCredentials } from '$lib/server/hosts/index.js';
|
||||
import { hostApiError, requireManagementOrigin } from '$lib/server/hosts/http.js';
|
||||
type Event = { request: Request; params: { id: string } };
|
||||
export function DELETE({ request, params }: Event) {
|
||||
requireManagementOrigin(request);
|
||||
try { revokeCredentials(params.id); return json({ ok: true }); } catch (cause) { return hostApiError(cause); }
|
||||
}
|
||||
export function POST({ request, params }: Event) {
|
||||
requireManagementOrigin(request);
|
||||
try { const operation = queueHostOperation(params.id, 'credentials.rotate', {}); return json({ operationId: operation.id }, { status: 202 }); } catch (cause) { return hostApiError(cause); }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createEnrollment } from '$lib/server/hosts/index.js';
|
||||
import { hostApiError, installerCommand, requireManagementOrigin } from '$lib/server/hosts/http.js';
|
||||
export function POST({ request, params }: { request: Request; params: { id: string } }) {
|
||||
requireManagementOrigin(request);
|
||||
try { const enrollment = createEnrollment(params.id); return json({ enrollment: { ...enrollment, command: installerCommand(request.url, enrollment.token) } }, { headers: { 'Cache-Control': 'no-store' } }); }
|
||||
catch (cause) { return hostApiError(cause); }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { queueHostOperation } from '$lib/server/hosts/index.js';
|
||||
import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
|
||||
export async function POST({ request, params }: { request: Request; params: { id: string } }) {
|
||||
requireManagementOrigin(request);
|
||||
try {
|
||||
const body = await objectBody(request);
|
||||
if (body.kind !== 'runtime.pull' || typeof body.image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9./_:@-]{0,511}$/.test(body.image)) throw new Error('Provide a runtime.pull request and a valid container image.');
|
||||
const operation = queueHostOperation(params.id, 'runtime.pull', { image: body.image });
|
||||
return json({ operationId: operation.id }, { status: 202 });
|
||||
} catch (cause) { return hostApiError(cause); }
|
||||
}
|
||||
@@ -7,17 +7,24 @@ import { queueCreateServer } from '$lib/server/provisioning/index.js';
|
||||
import { config, komodoConfigured, rconConfigured } from '$lib/server/config.js';
|
||||
import type { GameMode, ZombieGame } from '$lib/server/fleet/map-aliases.js';
|
||||
import { runningVersions } from '$lib/server/integrations/plutonium.js';
|
||||
import { listHosts } from '$lib/server/hosts/index.js';
|
||||
import { nativeInstance } from '$lib/server/backends/native.js';
|
||||
|
||||
export async function GET() {
|
||||
const rconHost = config.rcon.host;
|
||||
const hosts = listHosts();
|
||||
const hostsById = new Map(hosts.map(host => [host.id, host]));
|
||||
const versions = new Map(runningVersions().map(version => [version.server, version]));
|
||||
const servers = listServers().map((s) => {
|
||||
const instance = s.native_instance_id ? nativeInstance(s.name) : null;
|
||||
return {
|
||||
...serverObservation(s),
|
||||
host: s.host_id ? hostsById.get(s.host_id) ?? null : null,
|
||||
network: instance ? { bindAddress: instance.bind_address, localPort: instance.port, publicAddress: instance.public_address, publicPort: instance.public_port } : null,
|
||||
plutonium: versions.get(s.name),
|
||||
// Derived endpoint — same logic as endpointFor() but without asking
|
||||
// Komodo. If we don't have a port yet, endpoint is null.
|
||||
endpoint: s.game_port ? { host: s.rcon_host ?? rconHost, port: s.game_port } : null,
|
||||
endpoint: instance ? { host: `agent:${instance.host_id}`, port: instance.port } : s.game_port ? { host: s.rcon_host ?? rconHost, port: s.game_port } : null,
|
||||
held: [...heldByBeta(s.name).entries()].map(([target, betaId]) => ({ target, betaId }))
|
||||
};
|
||||
});
|
||||
@@ -26,6 +33,9 @@ export async function GET() {
|
||||
serverNow: Date.now(),
|
||||
reservedNames: (db().prepare("SELECT j.server FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id WHERE d.state IN ('queued', 'running', 'awaiting-health')").all() as { server: string }[]).map(row => row.server),
|
||||
integrations: {
|
||||
native: hosts.length > 0 || !komodoConfigured(),
|
||||
hostsReady: hosts.filter(host => host.readiness === 'ready').length,
|
||||
hostsTotal: hosts.length,
|
||||
rcon: rconConfigured(),
|
||||
komodo: komodoConfigured(),
|
||||
rconTimeoutMs: config.rcon.timeoutMs,
|
||||
@@ -36,6 +46,7 @@ export async function GET() {
|
||||
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
hostId?: string; libraryId?: string; image?: string; bindAddress?: string; publicAddress?: string; publicPort?: number;
|
||||
game?: string;
|
||||
gameMode?: string;
|
||||
mapSlug?: string;
|
||||
@@ -56,7 +67,7 @@ export async function POST({ request }: { request: Request }) {
|
||||
plutoKey: body.plutoKey,
|
||||
expectedName: body.expectedName,
|
||||
previewId: body.previewId,
|
||||
createdBy: 'ui'
|
||||
createdBy: 'ui', hostId: body.hostId, libraryId: body.libraryId, image: body.image, bindAddress: body.bindAddress, publicAddress: body.publicAddress, publicPort: body.publicPort
|
||||
});
|
||||
if (!result.ok) {
|
||||
const status = result.kind === 'bad-request' ? 400 : result.kind === 'unconfigured' ? 503
|
||||
|
||||
@@ -6,12 +6,31 @@ import { serverObservation } from '$lib/server/fleet/status.js';
|
||||
import { freshStatusTimestamp } from '$lib/server-freshness.js';
|
||||
import { tcpProbe } from '$lib/server/integrations/netProbe.js';
|
||||
import type { SetupCheck, SystemStatus } from '$lib/system-status.js';
|
||||
import { listHosts } from '$lib/server/hosts/index.js';
|
||||
import { listLibraries } from '$lib/server/game-files/index.js';
|
||||
|
||||
export async function GET({ url }: { url: URL }) {
|
||||
const servers = listServers();
|
||||
const readyServers = servers.filter(server => serverObservation(server).health === 'online').length;
|
||||
const rconServers = servers.filter(server => !server.last_error && freshStatusTimestamp(server.last_seen_at, Date.now()) && server.container_state === 'running').length;
|
||||
const savedKeys = listServerKeys().length;
|
||||
const hosts = listHosts();
|
||||
if (hosts.length || !komodoConfigured()) {
|
||||
const readyHosts = hosts.filter(host => host.readiness === 'ready');
|
||||
const connected = hosts.filter(host => host.connected);
|
||||
const versions = listLibraries().flatMap(library => library.versions).filter(version => version.state === 'ready');
|
||||
const cached = versions.filter(version => version.caches.some(cache => cache.status === 'ready' && readyHosts.some(host => host.id === cache.hostId)));
|
||||
const runtimes = connected.filter(host => host.inventory.runtimeImages?.length);
|
||||
const checks: SetupCheck[] = [
|
||||
{ id: 'hosts', title: 'Host agents', state: readyHosts.length ? 'ready' : hosts.length ? 'attention' : 'unconfigured', detail: `${connected.length} connected, ${readyHosts.length} deployment-ready, ${hosts.length} enrolled or awaiting enrollment.`, href: '/hosts', action: hosts.length ? 'Review hosts' : 'Add host' },
|
||||
{ id: 'runtime', title: 'Plutainer runtime', state: runtimes.length ? 'ready' : 'attention', detail: runtimes.length ? `${runtimes.length} connected hosts report cached runtime images.` : 'Prepare a Plutainer image on the host before creating a server.', href: '/hosts', action: 'Prepare runtime' },
|
||||
{ id: 'game-files', title: 'Verified game-file libraries', state: cached.length ? 'ready' : 'attention', detail: `${versions.length} published versions; ${cached.length} have a verified cache on a ready host.`, href: '/game-files', action: 'Manage game files' },
|
||||
{ id: 'rcon', title: 'Agent-local game connection', state: rconServers ? 'ready' : 'unverified', detail: rconServers ? `${rconServers} servers have fresh RCON responses.` : 'The host agent checks RCON locally after deployment. Controller access to game ports is not required.', href: servers.length ? '/' : '/hosts', action: servers.length ? 'Review fleet' : 'Review hosts' },
|
||||
{ id: 'keys', title: 'Saved server keys', state: savedKeys ? 'ready' : 'attention', detail: `${savedKeys} saved ${savedKeys === 1 ? 'key' : 'keys'}. Validity is checked with Plutonium at startup.`, href: '/server-keys', action: 'Manage keys' },
|
||||
{ id: 'first-server', title: 'First server ready', state: readyServers ? 'ready' : 'attention', detail: readyServers ? `${readyServers} of ${servers.length} servers are responding.` : servers.length ? 'Registered servers are awaiting fresh healthy reports. Inspect their host and diagnostics.' : 'Choose a ready host and a game library when creating your first server.', href: servers.length ? '/' : '/provisioning', action: servers.length ? 'Review fleet' : 'Create a server' }
|
||||
];
|
||||
return json({ checkedAt: new Date().toISOString(), checks, readyServers, totalServers: servers.length, savedKeys } satisfies SystemStatus, { headers: { 'cache-control': 'no-store' } });
|
||||
}
|
||||
const missing = [!config.komodo.address && 'KOMODO_ADDRESS', !config.komodo.apiKey && 'KOMODO_API_KEY', !config.komodo.apiSecret && 'KOMODO_API_SECRET'].filter(Boolean);
|
||||
const connection: SetupCheck = { id: 'komodo', title: 'Komodo connection', state: komodoConfigured() ? 'unverified' : 'unconfigured', detail: komodoConfigured() ? 'Credentials configured. Run connection check to verify host reachability.' : `Set ${missing.join(', ')} in the app environment.`, href: '#setup-guide', action: 'Setup instructions' };
|
||||
if (komodoConfigured() && url.searchParams.get('probe') === '1') {
|
||||
|
||||
@@ -6,8 +6,18 @@ import { recordTelemetryRequest } from '$lib/server/fleet/telemetry-requests.js'
|
||||
import { recordGameEvent } from '$lib/server/fleet/beta-tests.js';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { emit } from '$lib/server/pipeline/bus.js';
|
||||
import { authenticateTelemetry } from '$lib/server/auth/telemetry.js';
|
||||
|
||||
export async function POST({ request }: { request: Request }) {
|
||||
const authentication = authenticateTelemetry(request);
|
||||
if (!authentication.ok) {
|
||||
// Unauthenticated bodies are never parsed, attributed to a server, or
|
||||
// retained in telemetry diagnostics; they cannot affect game evidence.
|
||||
return json({ ok: false, error: authentication.error }, {
|
||||
status: authentication.status,
|
||||
headers: { 'Cache-Control': 'no-store' }
|
||||
});
|
||||
}
|
||||
const receivedAt = new Date().toISOString();
|
||||
const contentType = request.headers.get('content-type');
|
||||
let value: unknown;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { createUser, listUsers } from '$lib/server/auth/index.js';
|
||||
import { authBody, authError, requireUser } from '$lib/server/auth/http.js';
|
||||
export const GET: RequestHandler = () => json({ users:listUsers() });
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
try { return json({ user:await createUser(requireUser(locals.user).id,await authBody(request)) },{status:201}); }
|
||||
catch (cause) { return authError(cause); }
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { updateUser } from '$lib/server/auth/index.js';
|
||||
import { authBody, authError, requireUser } from '$lib/server/auth/http.js';
|
||||
export const PATCH: RequestHandler = async ({ request, params, locals }) => {
|
||||
try { return json({ user:await updateUser(requireUser(locals.user).id,params.id,await authBody(request)) }); }
|
||||
catch (cause) { return authError(cause); }
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { revokeUserSessions } from '$lib/server/auth/index.js';
|
||||
import { authError, requireUser } from '$lib/server/auth/http.js';
|
||||
export const POST: RequestHandler = ({ params, locals }) => {
|
||||
try { revokeUserSessions(requireUser(locals.user).id,params.id); return json({ok:true}); }
|
||||
catch (cause) { return authError(cause); }
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { listAuthAudit } from '$lib/server/auth/index.js';
|
||||
export function GET() { return json({ events:listAuthAudit() }); }
|
||||
@@ -29,6 +29,7 @@
|
||||
let tab = $state('collections');
|
||||
let limit = $state(25);
|
||||
let launchLimit = $state(10);
|
||||
const canOperate = $derived($page.data.user?.role === 'admin' || $page.data.user?.role === 'operator');
|
||||
const activeLaunches = $derived(launches.filter(launch => launch.active > 0));
|
||||
const launchHistory = $derived(launches.filter(launch => launch.active === 0));
|
||||
const visibleLaunches = $derived(tab === 'active' ? activeLaunches : launchHistory);
|
||||
@@ -92,10 +93,10 @@
|
||||
<svelte:head><title>Collections | Electric Cherry</title></svelte:head>
|
||||
<div class="page">
|
||||
<SectionHeader title="Collections" subtitle="Save your lineup, preview fleet changes, and follow launch progress and history.">
|
||||
{#snippet actions()}<Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create collection</Button>{/snippet}
|
||||
{#snippet actions()}{#if canOperate}<Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create collection</Button>{/if}{/snippet}
|
||||
</SectionHeader>
|
||||
<BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
|
||||
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
|
||||
{#if canOperate}<BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
|
||||
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />{/if}
|
||||
<Tabs {tabs} bind:value={tab} onchange={() => { launchLimit = 10; saveContext(); }} ariaLabel="Collection views" idPrefix="collection-tabs" panelId="collection-panel" />
|
||||
<div id="collection-panel" role="tabpanel" aria-labelledby={"collection-tabs-" + tab} class="panel">
|
||||
{#if tab === 'collections'}
|
||||
@@ -103,7 +104,7 @@
|
||||
<h2>Your collections <span>{blueprints.length}</span></h2>
|
||||
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
|
||||
{#if blueprintsLoading}<p>Loading collections…</p>
|
||||
{:else if !blueprints.length}<div class="blueprint-empty"><strong>Plan the whole lineup.</strong><p>Add maps from either game, choose the total lobbies each needs, and fill any gaps from one collection.</p><button onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create your first collection →</button></div>
|
||||
{:else if !blueprints.length}<div class="blueprint-empty"><strong>No collections yet.</strong><p>Collections define the maps and lobby counts for a fleet. Operators can create and launch them.</p>{#if canOperate}<button onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create your first collection →</button>{/if}</div>
|
||||
{:else}
|
||||
<div class="blueprints">
|
||||
{#each blueprints.slice(0,limit) as blueprint (blueprint.id)}
|
||||
@@ -115,11 +116,11 @@
|
||||
<h3>{blueprint.name}</h3>
|
||||
<p>{blueprint.description || 'Fill missing lobbies or match your fleet to this lineup exactly.'}</p>
|
||||
<ul class="blueprint-lineup">{#each blueprint.entries as entry}<li><strong>{entry.count}×</strong> {entry.mapName} <small>{entry.game.toUpperCase()}</small></li>{/each}</ul>
|
||||
<div class="blueprint-footer">
|
||||
{#if canOperate}<div class="blueprint-footer">
|
||||
<Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Preview fleet changes →</Button>
|
||||
<button class="edit" onclick={() => { editingBlueprint = blueprint; builderOpen = true; }}>Edit</button>
|
||||
<button class="remove" disabled={!!removing} onclick={() => removeBlueprint(blueprint.id)}>Remove</button>
|
||||
</div>
|
||||
</div>{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { GameLibrary, GameLibraryVersion } from '$lib/game-files.js';
|
||||
import { formatGameBytes } from '$lib/game-files.js';
|
||||
import type { Host } from '$lib/hosts.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
|
||||
type OverviewLibrary = Omit<GameLibrary, 'versions'> & { versions: Omit<GameLibraryVersion, 'files'>[] };
|
||||
let libraries = $state<OverviewLibrary[]>([]);
|
||||
let hosts = $state<Host[]>([]);
|
||||
let loaded = $state(false);
|
||||
let error = $state(''); let notice = $state(''); let busy = $state(false);
|
||||
let showImport = $state(false); let game = $state<'t5' | 't6'>('t5');
|
||||
let name = $state(''); let version = $state(''); let description = $state(''); let dlc = $state(''); let hostId = $state('');
|
||||
let filterGame = $state('all');
|
||||
let targets = $state<Record<string, string>>({});
|
||||
const canManage = $derived($page.data.user?.role === 'admin');
|
||||
const onlineHosts = $derived(hosts.filter(host => host.connected && host.credentialActive && host.mode !== 'disabled'));
|
||||
const matching = $derived(libraries.filter(library => filterGame === 'all' || library.game === filterGame));
|
||||
const hostName = (id: string) => hosts.find(host => host.id === id)?.name ?? id;
|
||||
async function load() {
|
||||
try {
|
||||
const response = await fetch('/api/game-files');
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not load game libraries.');
|
||||
libraries = body.libraries; hosts = body.hosts; loaded = true;
|
||||
} catch (cause) { error = (cause as Error).message; }
|
||||
}
|
||||
async function act(url: string, payload: Record<string, unknown>, message: string) {
|
||||
busy = true; error = ''; notice = '';
|
||||
try {
|
||||
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error ?? 'Could not start this operation.');
|
||||
notice = message; await load(); return true;
|
||||
} catch (cause) { error = (cause as Error).message; return false; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
async function startImport(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (await act('/api/game-files', { game, name, version, description, dlc: dlc.split(',').map(value => value.trim()).filter(Boolean), hostId }, 'Import queued. The source host will scan and upload files, then verify and publish this version.')) showImport = false;
|
||||
}
|
||||
function newVersion(library: OverviewLibrary) {
|
||||
showImport = true; game = library.game; name = library.name; description = library.description;
|
||||
dlc = library.versions[0]?.dlc.join(', ') ?? ''; version = '';
|
||||
document.getElementById('library-import')?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
onMount(() => pollVisible(load, 5000));
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Game Files | Electric Cherry</title></svelte:head>
|
||||
<div class="page">
|
||||
<SectionHeader title="Game Files" subtitle="Import immutable base-game versions and distribute verified copies to your hosts.">
|
||||
{#snippet actions()}{#if canManage}<Button onclick={() => showImport = !showImport}>{showImport ? 'Close import' : 'Import game files'}</Button>{/if}{/snippet}
|
||||
</SectionHeader>
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||
{#if notice}<p role="status">{notice}</p>{/if}
|
||||
{#if !canManage}<p class="muted">Administrators import game files and distribute verified copies to hosts.</p>{/if}
|
||||
{#if canManage && showImport}
|
||||
<section class="card" id="library-import">
|
||||
<h2>Import an existing game volume</h2>
|
||||
<p>The selected host scans its {game}_game_files Docker volume, calculates SHA256 checksums, and streams a copy into controller storage. Publication requires every file to pass verification.</p>
|
||||
<form onsubmit={startImport}>
|
||||
<div class="fields">
|
||||
<label>Game<select bind:value={game}><option value="t5">Black Ops I (T5)</option><option value="t6">Black Ops II (T6)</option></select></label>
|
||||
<label>Source host<select required bind:value={hostId}><option value="">Select an online host</option>{#each onlineHosts as host}<option value={host.id}>{host.name}</option>{/each}</select></label>
|
||||
<label>Library name<input required maxlength="160" bind:value={name} placeholder="Black Ops II — Complete Server Files" /></label>
|
||||
<label>New version label<input required maxlength="120" bind:value={version} placeholder="2026-10-03" /></label>
|
||||
<label class="wide">Description<textarea maxlength="4000" bind:value={description} rows="2"></textarea></label>
|
||||
<label class="wide">Content / DLC (comma separated)<input bind:value={dlc} placeholder="Base game, Nuketown, Die Rise, Mob of the Dead, Buried, Origins" /></label>
|
||||
</div>
|
||||
<Button type="submit" disabled={busy || !onlineHosts.length}>Start import</Button>
|
||||
{#if !onlineHosts.length}<p>Enroll an online source host in <a href="/hosts">Hosts</a> before importing.</p>{/if}
|
||||
</form>
|
||||
</section>
|
||||
{/if}
|
||||
<div class="filters"><label>Game<select bind:value={filterGame}><option value="all">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label><span class="muted">{libraries.length} libraries · Base-game files and runtime images are versioned separately.</span></div>
|
||||
{#each matching as library (library.id)}
|
||||
<section class="card">
|
||||
<div class="heading"><div><h2>{library.name}</h2><p class="muted">{library.game.toUpperCase()} · {library.description}</p></div>{#if canManage}<Button size="sm" onclick={() => newVersion(library)}>Import new version</Button>{/if}</div>
|
||||
{#each library.versions as item (item.id)}
|
||||
<article class="version">
|
||||
<div class="heading"><h3>{item.version}</h3><Badge variant={item.state === 'failed' ? 'danger' : item.state === 'ready' ? 'online' : 'info'}>{item.state === 'ready' ? 'Published' : item.state === 'importing' ? 'Importing' : 'Failed'}</Badge></div>
|
||||
<p>{formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files · Created {new Date(item.createdAt).toLocaleString()}</p>
|
||||
{#if item.dlc.length}<p>Content: {item.dlc.join(', ')}</p>{/if}
|
||||
{#if item.error}<p class="ui-error">{item.error}</p>{/if}
|
||||
<details><summary>Version details</summary><dl><dt>Immutable version ID</dt><dd>{item.id}</dd><dt>Manifest SHA256</dt><dd>{item.manifestSha256 ?? 'Waiting for source scan'}</dd><dt>Controller storage</dt><dd>{item.storageLocation}</dd><dt>Imported from</dt><dd>{hostName(item.sourceHostId)} / {item.sourceVolume}</dd></dl><a href={`/api/game-files/${item.id}`} target="_blank" rel="noreferrer">View complete file manifest</a></details>
|
||||
{#if item.state === 'ready'}
|
||||
{#if canManage}<div class="download"><label>Download to host<select bind:value={targets[item.id]}><option value="">Select host</option>{#each onlineHosts as host}<option value={host.id}>{host.name} · {formatGameBytes(Number(host.inventory.storageAvailable ?? 0))} free</option>{/each}</select></label><Button size="sm" disabled={busy || !targets[item.id]} onclick={() => act(`/api/game-files/${item.id}/download`, { hostId: targets[item.id] }, 'Library download queued. Progress will update below.')}>Download / verify</Button></div>{/if}
|
||||
<div class="table-wrap"><table><thead><tr><th>Host</th><th>Cache state</th><th>Progress</th><th>Detail</th></tr></thead><tbody>
|
||||
{#each item.caches as cache}<tr><td><a href={`/hosts/${cache.hostId}`}>{hostName(cache.hostId)}</a></td><td>{cache.status}{!hosts.find(host => host.id === cache.hostId)?.connected ? ' · offline' : ''}</td><td>{#if cache.status === 'downloading' || cache.status === 'verifying'}<progress max={cache.bytesTotal || 1} value={cache.bytesDownloaded}></progress><small>{formatGameBytes(cache.bytesDownloaded)} / {formatGameBytes(cache.bytesTotal)}</small>{:else if cache.status === 'ready' || cache.status === 'outdated'}Verified{cache.status === 'outdated' ? ' · older version' : ''}{:else}—{/if}</td><td>{cache.error ?? ''}</td></tr>{:else}<tr><td colspan="4">Enroll a host to cache this version.</td></tr>{/each}
|
||||
</tbody></table></div>
|
||||
{:else if item.state === 'importing'}<p class="muted">The agent operation continues independently of this page. An interrupted import is reconciled when the host reconnects.</p>{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</section>
|
||||
{:else}{#if loaded}<EmptyState title="No game libraries yet" description="Administrators can import T5 or T6 game files from an enrolled host. Each published version can be shared by many instances." />{:else}<p role="status">Loading game libraries…</p>{/if}{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page, .card, .version, form { display: grid; gap: 20px; }
|
||||
.card { padding: 24px; border: 1px solid var(--border-subtle); background: var(--surface); border-radius: var(--radius-md); }
|
||||
.version { padding-top: 20px; border-top: 1px solid var(--border-subtle); }
|
||||
.heading, .filters, .download { display: flex; gap: 16px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
|
||||
.download { justify-content: flex-start; align-items: end; }
|
||||
.fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.wide { grid-column: 1 / -1; } label { display: grid; gap: 6px; font-size: var(--text-sm); }
|
||||
input, select, textarea { width: 100%; min-width: 0; box-sizing: border-box; padding: 10px; background: var(--surface); color: inherit; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); }
|
||||
h2, h3, p { margin: 0; } h2 { font-size: var(--text-md); } h3 { font-size: var(--text-sm); }
|
||||
p, td, th, details { font-size: var(--text-sm); line-height: 1.6; } .muted, small { color: var(--text-secondary); }
|
||||
small { display: block; } a { color: var(--accent-primary-hover); } summary { cursor: pointer; }
|
||||
dl { display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; } dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; text-align: left; } th, td { padding: 10px; border-bottom: 1px solid var(--border-subtle); }
|
||||
@media(max-width: 640px) { .fields { grid-template-columns: 1fr; } .card { padding: 16px; } dl { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { Host } from '$lib/hosts.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
let hosts = $state<Host[]>([]);
|
||||
let loading = $state(true); let busy = $state(false); let error = $state('');
|
||||
let name = $state(''); let portMin = $state(28960); let portMax = $state(28980);
|
||||
let enrollment = $state<{ command: string; expiresAt: string; hostId: string } | null>(null);
|
||||
const canManage = $derived($page.data.user?.role === 'admin');
|
||||
async function load() {
|
||||
try { const response = await fetch('/api/hosts'); const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not load hosts.'); hosts = body.hosts; }
|
||||
catch (cause) { error = (cause as Error).message; } finally { loading = false; }
|
||||
}
|
||||
async function add(event: SubmitEvent) {
|
||||
event.preventDefault(); busy = true; error = ''; enrollment = null;
|
||||
try {
|
||||
const response = await fetch('/api/hosts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, portMin, portMax }) });
|
||||
const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not add host.');
|
||||
enrollment = body.enrollment; name = ''; await load();
|
||||
} catch (cause) { error = (cause as Error).message; } finally { busy = false; }
|
||||
}
|
||||
onMount(() => { void load(); const timer = setInterval(() => { void load(); }, 15000); return () => clearInterval(timer); });
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Hosts · Electric Cherry</title></svelte:head>
|
||||
<section class="page">
|
||||
<SectionHeader title="Hosts" subtitle="Connect game-server hosts and prepare them for deployment." />
|
||||
{#if error}<p role="alert" class="ui-error">{error}</p>{/if}
|
||||
{#if canManage}<section class="panel">
|
||||
<h2>Add host</h2><p>Run the installation command on a Debian 12 or 13 amd64 machine. Each host connects to this controller over HTTPS.</p>
|
||||
<form onsubmit={add}><fieldset disabled={busy}>
|
||||
<label>Friendly name<input required maxlength="200" bind:value={name} placeholder="Game host 1" /></label>
|
||||
<label>First game port<input type="number" required min="1" max="65535" bind:value={portMin} /></label>
|
||||
<label>Last game port<input type="number" required min={portMin} max="65535" bind:value={portMax} /></label>
|
||||
<Button type="submit" disabled={busy}>{busy ? 'Creating…' : 'Add host'}</Button>
|
||||
</fieldset></form>
|
||||
{#if enrollment}<div class="enrollment" role="status"><h3>Install the agent</h3><p>This single-use command expires {new Date(enrollment.expiresAt).toLocaleString()}. Run it with administrator access on your game host.</p><pre>{enrollment.command}</pre><a href={`/hosts/${enrollment.hostId}`}>Open host setup →</a></div>{/if}
|
||||
</section>{:else}<p>Administrators manage host setup, runtime preparation, and credentials.</p>{/if}
|
||||
{#if loading}<p role="status">Loading hosts…</p>{:else if !hosts.length}<p>No hosts connected yet.{canManage ? ' Add a host to begin.' : ''}</p>{:else}
|
||||
<div class="host-grid">{#each hosts as host}<a class="panel host" href={`/hosts/${host.id}`}><div class="row"><h2>{host.name}</h2><span class:ready={host.readiness === 'ready'} class="state">{host.readiness}</span></div><p>{host.hostname || 'Awaiting enrollment'} · {host.connectionState}</p><p>{host.inventory.os ?? 'OS pending'} · {host.inventory.architecture ?? 'Architecture pending'}</p><p>Game ports {host.portMin}–{host.portMax}</p>{#if host.readinessReasons.length}<p class="reason">{host.readinessReasons[0]}</p>{/if}</a>{/each}</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; }
|
||||
h2 { font-size:var(--text-md); margin:0; } h3 { font-size:var(--text-sm); } p { color:var(--text-secondary); line-height:1.6; font-size:var(--text-sm); }
|
||||
fieldset { display:flex; gap:16px; flex-wrap:wrap; align-items:end; border:0; padding:0; margin-top:20px; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; max-width:280px; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); }
|
||||
pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-sm); } .enrollment { border-top:1px solid var(--border-subtle); margin-top:24px; padding-top:16px; }
|
||||
.host-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:16px; } .host { text-decoration:none; color:inherit; } .host:hover { border-color:var(--accent-primary); } .row { display:flex; justify-content:space-between; gap:16px; } .state { font-size:var(--text-xs); text-transform:capitalize; color:var(--text-secondary); } .ready { color:var(--accent-primary); } .reason { margin-bottom:0; }
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { Host, HostOperation, HostMode } from '$lib/hosts.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
let host = $state<Host | null>(null); let operations = $state<HostOperation[]>([]);
|
||||
let loading = $state(true); let busy = $state(false); let error = $state(''); let notice = $state('');
|
||||
let name = $state(''); let portMin = $state(28960); let portMax = $state(28980); let bindAddress = $state('0.0.0.0'); let publicAddress = $state(''); let minimumFreeGB = $state(1);
|
||||
let runtimeImage = $state('ghcr.io/ayymoss/plutainer:latest'); let enrollment = $state<{ command: string; expiresAt: string } | null>(null);
|
||||
const endpoint = $derived(`/api/hosts/${$page.params.id}`);
|
||||
const canManage = $derived($page.data.user?.role === 'admin');
|
||||
function bytes(value: unknown) { return typeof value === 'number' ? `${(value / 1073741824).toFixed(1)} GiB` : 'Unknown'; }
|
||||
async function load(initialize = false) {
|
||||
try {
|
||||
const response = await fetch(endpoint); const body = await response.json(); if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not load host.'); host = body.host; operations = body.operations;
|
||||
if (initialize && host) { name = host.name; portMin = host.portMin; portMax = host.portMax; bindAddress = host.bindAddress; publicAddress = host.publicAddress; minimumFreeGB = host.minimumFreeBytes / 1073741824; }
|
||||
} catch (cause) { error = (cause as Error).message; } finally { loading = false; }
|
||||
}
|
||||
async function action(path: string, method: string, body?: unknown, message?: string) {
|
||||
busy = true; error = ''; notice = '';
|
||||
try { const response = await fetch(endpoint + path, { method, headers: { 'Content-Type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); const result = await response.json(); if (!response.ok) throw new Error(result.error ?? 'Host request failed.'); if (result.enrollment) enrollment = result.enrollment; notice = message ?? ''; await load(); }
|
||||
catch (cause) { error = (cause as Error).message; } finally { busy = false; }
|
||||
}
|
||||
async function save(event: SubmitEvent) { event.preventDefault(); await action('', 'PATCH', { name, portMin, portMax, bindAddress, publicAddress, minimumFreeBytes: Math.round(minimumFreeGB * 1073741824) }, 'Host settings saved.'); }
|
||||
async function mode(value: HostMode) { await action('', 'PATCH', { mode: value }, value === 'draining' ? 'Drain enabled. Existing games continue until safe retirement.' : `Host ${value === 'active' ? 'enabled' : 'disabled'}.`); }
|
||||
onMount(() => { void load(true); const timer = setInterval(() => { void load(); }, 10000); return () => clearInterval(timer); });
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{host?.name ?? 'Host'} · Electric Cherry</title></svelte:head>
|
||||
<section class="page"><a href="/hosts">← All hosts</a><SectionHeader title={host?.name ?? 'Host'} subtitle="Readiness, inventory, credentials, and operation history." />
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if}
|
||||
{#if loading}<p role="status">Loading host…</p>{:else if host}
|
||||
<section class="panel"><div class="row"><h2>{host.connectionState} · {host.readiness}</h2><span>Last heartbeat: {host.lastHeartbeat ? new Date(host.lastHeartbeat).toLocaleString() : 'Never'}</span></div>
|
||||
{#if host.readinessReasons.length}<ul>{#each host.readinessReasons as reason}<li>{reason}</li>{/each}</ul>{:else}<p>Connected and ready. Each deployment also checks its game library, runtime, and ports.</p>{/if}
|
||||
{#if canManage}<div class="actions"><Button disabled={busy || host.mode === 'active'} onclick={() => mode('active')}>Enable</Button><Button disabled={busy || host.mode === 'draining'} onclick={() => mode('draining')}>Drain host</Button><Button disabled={busy || host.mode === 'disabled'} onclick={() => mode('disabled')}>Disable host</Button></div>{/if}
|
||||
<p>Draining prevents new deployments and retires instances as they become safe to remove. Disabled hosts receive no new commands; running game containers continue.</p>
|
||||
</section>
|
||||
<div class="grid"><section class="panel"><h2>Inventory</h2><dl>
|
||||
<dt>Hostname</dt><dd>{host.hostname || 'Pending'}</dd><dt>Agent</dt><dd>{host.inventory.agentVersion ?? 'Unknown'}</dd><dt>Platform</dt><dd>{host.inventory.os ?? 'Unknown'} · {host.inventory.architecture ?? 'Unknown'}</dd><dt>CPU</dt><dd>{host.inventory.cpu ?? 'Unknown'} ({host.inventory.cpuCount ?? '?'} cores)</dd><dt>Memory available / total</dt><dd>{bytes(host.inventory.memoryAvailable)} / {bytes(host.inventory.memoryTotal)}</dd><dt>Game storage available / total</dt><dd>{bytes(host.inventory.storageAvailable)} / {bytes(host.inventory.storageTotal)}</dd><dt>Docker</dt><dd>{host.inventory.dockerVersion ?? 'Unknown'} · {host.inventory.dockerRunning ? 'Running' : 'Unavailable'}</dd><dt>Current operation</dt><dd>{host.inventory.currentOperation ?? 'None reported'}</dd>
|
||||
</dl></section><section class="panel"><h2>Host configuration</h2>{#if canManage}<form onsubmit={save}><fieldset disabled={busy}>
|
||||
<label>Friendly name<input required maxlength="200" bind:value={name} /></label><div class="fields"><label>First game port<input type="number" min="1" max="65535" required bind:value={portMin} /></label><label>Last game port<input type="number" min={portMin} max="65535" required bind:value={portMax} /></label></div><label>Local bind address<input required bind:value={bindAddress} /></label><label>Public / NAT address<input bind:value={publicAddress} placeholder="Optional public hostname or address" /></label><label>Minimum free game storage (GiB)<input type="number" min="0" step="0.1" required bind:value={minimumFreeGB} /></label><Button type="submit" disabled={busy}>Save settings</Button>
|
||||
</fieldset></form>{:else}<dl><dt>Friendly name</dt><dd>{host.name}</dd><dt>Game ports</dt><dd>{host.portMin}–{host.portMax}</dd><dt>Local bind address</dt><dd>{host.bindAddress}</dd><dt>Public / NAT address</dt><dd>{host.publicAddress || 'Not configured'}</dd><dt>Minimum free storage</dt><dd>{bytes(host.minimumFreeBytes)}</dd></dl><p>Host configuration is managed by administrators.</p>{/if}<p>Public addressing is separate from local port reservations. Configure your firewall and NAT for the game-port range.</p></section></div>
|
||||
<div class="grid"><section class="panel"><h2>Game files and runtime</h2><p><a href="/game-files">Manage game-file libraries →</a></p>{#if host.inventory.libraries?.length}<ul>{#each host.inventory.libraries as library}<li>{library.id}: {library.state}{library.progress !== undefined ? ` (${library.progress}%)` : ''}</li>{/each}</ul>{:else}<p>No cached game libraries reported.</p>{/if}
|
||||
{#if canManage}<form onsubmit={(event) => { event.preventDefault(); void action('/operations', 'POST', { kind: 'runtime.pull', image: runtimeImage }, 'Runtime download queued.'); }}><fieldset disabled={busy}><label>Plutainer image<input required bind:value={runtimeImage} /></label><Button type="submit" disabled={busy || !host.connected}>Prepare runtime</Button></fieldset></form>{/if}<p>Cached images: {host.inventory.runtimeImages?.join(', ') || 'None reported'}</p>
|
||||
</section><section class="panel"><h2>Agent access</h2><p>Credentials: {host.credentialActive ? 'Active' : 'Not active'}. Enrollment links expire after 15 minutes and work once.</p>{#if canManage}<div class="actions"><Button disabled={busy} onclick={() => action('/enrollment', 'POST')}>Generate installer</Button><Button disabled={busy || !host.credentialActive || !host.connected} onclick={() => action('/credentials', 'POST', undefined, 'Credential rotation queued.')}>Rotate credential</Button><Button disabled={busy || !host.credentialActive} onclick={() => action('/credentials', 'DELETE', undefined, 'Host credentials revoked. Generate a new installer to enroll again.')}>Revoke credential</Button></div>
|
||||
{#if enrollment}<p>Expires {new Date(enrollment.expiresAt).toLocaleString()}</p><pre>{enrollment.command}</pre>{/if}{:else}<p>Administrators manage enrollment and agent credentials.</p>{/if}
|
||||
</section></div>
|
||||
<section class="panel"><h2>Operation history</h2>{#if !operations.length}<p>No remote operations yet.</p>{:else}<div class="table-wrap"><table><thead><tr><th>Operation</th><th>State</th><th>Updated</th><th>Result</th></tr></thead><tbody>{#each operations as operation}<tr><td>{operation.kind}<small>{operation.id}</small></td><td>{operation.state}</td><td>{new Date(operation.updatedAt).toLocaleString()}</td><td>{operation.error ?? '—'}</td></tr>{/each}</tbody></table></div>{/if}</section>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; min-width:0; } h2 { margin:0 0 16px; font-size:var(--text-md); text-transform:capitalize; } p, li, dd, dt, span { font-size:var(--text-sm); line-height:1.65; } p,li,dt,span { color:var(--text-secondary); } .grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; } .row,.actions,.fields { display:flex; gap:12px; flex-wrap:wrap; } .row { justify-content:space-between; } .actions { margin-top:20px; } dl { display:grid; grid-template-columns:1fr 1.3fr; gap:12px; margin:0; } dd { margin:0; overflow-wrap:anywhere; } fieldset { display:grid; gap:16px; border:0; padding:0; margin:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; min-width:0; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); } .fields>* { flex:1; min-width:120px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-xs); } .table-wrap { overflow:auto; } table { width:100%; border-collapse:collapse; text-align:left; font-size:var(--text-sm); } td,th { padding:12px; border-bottom:1px solid var(--border-subtle); } small { display:block; color:var(--text-secondary); font-size:var(--text-xs); margin-top:4px; } @media(max-width:900px) { .grid { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
let username = $state(''); let password = $state(''); let busy = $state(false); let error = $state('');
|
||||
function destination(): string {
|
||||
const next = $page.url.searchParams.get('next');
|
||||
if (!next?.startsWith('/') || next.startsWith('//') || /[\\\x00-\x1f]/.test(next)) return '/';
|
||||
const target = new URL(next, window.location.origin);
|
||||
return target.origin === window.location.origin && !['/login', '/setup'].includes(target.pathname) ? target.pathname + target.search + target.hash : '/';
|
||||
}
|
||||
async function login(event: SubmitEvent) {
|
||||
event.preventDefault(); if (busy) return; busy = true; error = '';
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not sign in. Try again.');
|
||||
password = '';
|
||||
window.location.assign(body.user?.mustChangePassword ? '/account' : destination());
|
||||
} catch (cause) { error = (cause as Error).message; password = ''; busy = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Sign in · Electric Cherry</title><meta name="robots" content="noindex" /></svelte:head>
|
||||
<section class="auth-card">
|
||||
<SectionHeader title="Sign in" subtitle="Your game servers, hosts, and operations in one place." />
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
<form onsubmit={login} aria-busy={busy}>
|
||||
<fieldset disabled={busy}>
|
||||
<label for="username">Username<input id="username" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required maxlength="64" bind:value={username} /></label>
|
||||
<label for="password">Password<input id="password" name="password" type="password" autocomplete="current-password" required maxlength="128" bind:value={password} /></label>
|
||||
<Button type="submit" variant="primary" loading={busy} disabled={busy}>Sign in</Button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<p class="help">Need access or a password reset? Contact an Electric Cherry administrator.</p>
|
||||
{#if $page.data.setupRequired}<a class="setup-link" href="/setup">Set up the first administrator →</a>{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.auth-card { padding:32px; border:1px solid var(--border-strong); border-radius:var(--radius-lg); background:var(--surface); box-shadow:0 18px 50px #0002; }
|
||||
form { margin-top:28px; } fieldset { display:grid; gap:20px; border:0; margin:0; padding:0; min-width:0; }
|
||||
label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; padding:12px; background:var(--surface-sunken); color:var(--text-primary); border:1px solid var(--border-control); border-radius:var(--radius-md); font:inherit; }
|
||||
.help,.setup-link { color:var(--text-secondary); font-size:var(--text-sm); line-height:1.6; } .help { margin:24px 0 0; } .setup-link { display:block; margin-top:16px; color:var(--accent-primary-hover); }
|
||||
.error { color:var(--state-danger); font-size:var(--text-sm); padding:12px; border:1px solid var(--state-danger); border-radius:var(--radius-md); overflow-wrap:anywhere; }
|
||||
@media(max-width:540px) { .auth-card { padding:24px; } }
|
||||
</style>
|
||||
@@ -117,7 +117,7 @@
|
||||
<div><dt>First detected</dt><dd>{$timeFormat.dateTime(incident.first_seen_at)}</dd></div>
|
||||
<div><dt>Last observed</dt><dd>{$timeFormat.dateTime(incident.last_seen_at)}</dd></div>
|
||||
{#if incident.finished_at}<div><dt>Finished</dt><dd>{$timeFormat.dateTime(incident.finished_at)}</dd></div>{/if}
|
||||
{#if evidence.containerState}<div><dt>Observed Komodo state</dt><dd>{evidence.containerState}</dd></div>{/if}
|
||||
{#if evidence.containerState}<div><dt>Observed container state</dt><dd>{evidence.containerState}</dd></div>{/if}
|
||||
</dl>
|
||||
{#if evidence.reason && evidence.reason !== incident.summary}<p class="observed-reason">Observed reason: {evidence.reason}</p>{/if}
|
||||
{#if !incident.server_deleted_at}<div class="links"><a href={`/servers/${encodeURIComponent(incident.server)}?tab=diagnostics`}>Diagnose connection</a><a href={`/servers/${encodeURIComponent(incident.server)}?tab=logs`}>Open server logs</a></div>{/if}
|
||||
|
||||
@@ -77,6 +77,9 @@
|
||||
rcon_status?: import('$lib/server-observation.js').GameObservation;
|
||||
telemetry?: import('$lib/telemetry.js').GameTelemetry;
|
||||
name: string;
|
||||
native_instance_id?: string | null; host_id?: string | null; instance_generation?: number | null; game_library_id?: string | null;
|
||||
host?: import('$lib/hosts.js').Host | null;
|
||||
network?: { bindAddress: string; localPort: number; publicAddress: string | null; publicPort: number | null } | null;
|
||||
komodo_type: string; komodo_name: string; komodo_server: string | null;
|
||||
game_port: number | null; rcon_host: string | null;
|
||||
game: string | null; map: string | null;
|
||||
@@ -103,6 +106,7 @@
|
||||
|
||||
let name = $derived($page.params.name ?? '');
|
||||
let server = $derived(($fleetServers.find(s => s.name === name) as Server | undefined) ?? null);
|
||||
const native = $derived(!!server?.native_instance_id);
|
||||
let integrations = $derived($fleetIntegrations);
|
||||
let history = $state<DeploymentRow[]>([]);
|
||||
let inventory = $state<InventoryEntry[]>([]);
|
||||
@@ -311,8 +315,8 @@
|
||||
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={refreshFleet}>Retry</Button></div>{/if}
|
||||
{#if hasDiagnosticIssue}
|
||||
<section class="connection-issue" aria-label="Server connection issue">
|
||||
<div><strong>{effectiveHealth === 'stale' ? 'Server report is outdated' : 'Server is not responding'}</strong><p>Player count is unknown. {effectiveSeen ? `Last successful report ${ago(effectiveSeen, $uiNow)}.` : 'No successful report has been received.'} {integrations && !integrations.rcon ? 'RCON is not configured.' : ''}</p></div>
|
||||
<div class="issue-actions"><Button size="sm" onclick={() => changeTab('diagnostics')}>Diagnose connection</Button><Button size="sm" variant="ghost" onclick={() => changeTab('logs')}>Open logs</Button>{#if integrations && (!integrations.rcon || !integrations.komodo)}<Button size="sm" href="/settings">Check system setup</Button>{/if}</div>
|
||||
<div><strong>{effectiveHealth === 'stale' ? 'Server report is outdated' : 'Server is not responding'}</strong><p>Player count is unknown. {effectiveSeen ? `Last successful report ${ago(effectiveSeen, $uiNow)}.` : 'No successful report has been received.'} {native && !server.host?.connected ? 'The host agent is offline; the game may still be running.' : !native && integrations && !integrations.rcon ? 'RCON is not configured.' : ''}</p></div>
|
||||
<div class="issue-actions"><Button size="sm" onclick={() => changeTab('diagnostics')}>Diagnose connection</Button><Button size="sm" variant="ghost" onclick={() => changeTab('logs')}>Open logs</Button>{#if native && server.host_id}<Button size="sm" href={`/hosts/${server.host_id}`}>Check host</Button>{:else if integrations && (!integrations.rcon || !integrations.komodo)}<Button size="sm" href="/settings">Check system setup</Button>{/if}</div>
|
||||
</section>
|
||||
{/if}
|
||||
<div class="tabs-row">
|
||||
@@ -382,7 +386,9 @@
|
||||
</div>
|
||||
|
||||
<p class="sub">Current player count: {server.status_source === 'telemetry' ? 'game telemetry' : server.status_source === 'rcon' ? 'RCON' : 'unavailable'}. RCON probes below show that connection's separate results.</p>
|
||||
{#if integrations}
|
||||
{#if native}
|
||||
<div class="diag-grid"><div class="kv"><span class="k">Game connection</span><span class="v">RCON runs locally through the host agent</span></div><div class="kv"><span class="k">Host agent</span><span class="v {server.host?.connected ? 'ok' : 'bad'}">{server.host?.connectionState ?? 'unknown'}</span></div><div class="kv"><span class="k">Last heartbeat</span><span class="v">{server.host?.lastHeartbeat ? ago(server.host.lastHeartbeat, $uiNow) : 'Not received'}</span></div></div>
|
||||
{:else if integrations}
|
||||
<div class="diag-grid">
|
||||
<div class="kv"><span class="k">Endpoint</span><span class="v mono">{endpointLabel(effectiveEndpoint)}</span></div>
|
||||
<div class="kv"><span class="k">RCON password</span><span class="v mono {integrations.rcon ? 'ok' : 'bad'}">{integrations.rcon ? 'configured' : 'missing'}</span></div>
|
||||
@@ -396,7 +402,7 @@
|
||||
<Badge variant="danger" uppercase>{effectiveReason}</Badge>
|
||||
<span class="alert-detail">{errorDetail}</span>
|
||||
</div>
|
||||
<div class="hint">{rconFixHint(effectiveReason)}</div>
|
||||
<div class="hint">{native ? 'Inspect the host agent connection and game logs. RCON is sent locally by the agent; public game-port forwarding does not affect this check.' : rconFixHint(effectiveReason)}</div>
|
||||
{:else if effectiveErr}
|
||||
<div class="alert alert-danger">
|
||||
<Badge variant="neutral" uppercase>Error</Badge>
|
||||
@@ -412,19 +418,19 @@
|
||||
{/if}
|
||||
|
||||
<div class="probe-actions">
|
||||
{#if effectiveEndpoint}
|
||||
{#if effectiveEndpoint || native}
|
||||
<Button variant="secondary" size="sm" onclick={probeRcon} disabled={probingRcon} loading={probingRcon}>
|
||||
Probe RCON
|
||||
{native ? 'Probe RCON through agent' : 'Probe RCON'}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if !effectiveEndpoint && integrations?.komodo}
|
||||
{#if !native && !effectiveEndpoint && integrations?.komodo}
|
||||
<Button variant="secondary" size="sm" onclick={probeKomodo} disabled={probing} loading={probing}>
|
||||
Probe Komodo
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="secondary" size="sm" onclick={diagnoseNetwork} disabled={diagnosing} loading={diagnosing}>
|
||||
{#if !native}<Button variant="secondary" size="sm" onclick={diagnoseNetwork} disabled={diagnosing} loading={diagnosing}>
|
||||
Diagnose network
|
||||
</Button>
|
||||
</Button>{:else if server.host_id}<Button variant="secondary" size="sm" href={`/hosts/${server.host_id}`}>Inspect host</Button>{/if}
|
||||
</div>
|
||||
|
||||
{#if rconProbe?.ok}
|
||||
@@ -438,7 +444,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if netDiagnosis}
|
||||
{#if !native && netDiagnosis}
|
||||
{#if netDiagnosis.ok && netDiagnosis.results}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll the table.) -->
|
||||
<div class="table-scroll" role="region" aria-label="Network diagnostics" tabindex="0">
|
||||
@@ -484,7 +490,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if komodoProbe}
|
||||
{#if !native && komodoProbe}
|
||||
{#if komodoProbe.ok}
|
||||
<div class="alert alert-ok">
|
||||
<Badge variant="online" uppercase>Resolved</Badge>
|
||||
@@ -521,8 +527,8 @@
|
||||
<h2>Configuration</h2>
|
||||
</div>
|
||||
<div class="diag-grid">
|
||||
<div class="kv"><span class="k">Komodo</span><span class="v mono">{server.komodo_type} · {server.komodo_name}</span></div>
|
||||
<div class="kv"><span class="k">RCON override</span><span class="v mono">{server.rcon_host ?? '(default)'}</span></div>
|
||||
{#if native}<div class="kv"><span class="k">Host</span><span class="v"><a href={`/hosts/${server.host_id}`}>{server.host?.name ?? server.host_id}</a></span></div><div class="kv"><span class="k">Instance</span><span class="v mono">{server.native_instance_id}</span></div><div class="kv"><span class="k">Generation</span><span class="v mono">{server.instance_generation}</span></div><div class="kv"><span class="k">Game-file version</span><span class="v mono"><a href="/game-files">{server.game_library_id}</a></span></div>{#if server.network}<div class="kv"><span class="k">Local UDP binding</span><span class="v mono">{server.network.bindAddress}:{server.network.localPort}</span></div><div class="kv"><span class="k">Public / NAT endpoint</span><span class="v mono">{server.network.publicAddress || server.host?.publicAddress || server.host?.hostname || 'Not configured'}:{server.network.publicPort ?? server.network.localPort}</span></div>{/if}
|
||||
{:else}<div class="kv"><span class="k">Komodo</span><span class="v mono">{server.komodo_type} · {server.komodo_name}</span></div><div class="kv"><span class="k">RCON override</span><span class="v mono">{server.rcon_host ?? '(default)'}</span></div>{/if}
|
||||
<div class="kv"><span class="k">Game port</span><span class="v mono">{server.game_port ?? '(unresolved)'}</span></div>
|
||||
<div class="kv"><span class="k">Game</span><span class="v mono">{server.game ?? '(unset)'}</span></div>
|
||||
<div class="kv"><span class="k">Map</span><span class="v mono">{server.map ?? '(unset)'}</span></div>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { AuthUser, UserRole } from '$lib/auth.js';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
let users = $state<AuthUser[]>([]); let loading = $state(true); let busy = $state(false);
|
||||
let error = $state(''); let notice = $state(''); let formError = $state('');
|
||||
let createOpen = $state(false); let editOpen = $state(false); let editing = $state<AuthUser | null>(null);
|
||||
let username = $state(''); let displayName = $state(''); let password = $state('');
|
||||
let role = $state<UserRole>('viewer'); let enabled = $state(true); let showPassword = $state(false);
|
||||
interface AuditEvent { id: number; actorId: string | null; actor: string | null; action: string; targetId: string | null; details: string; createdAt: number }
|
||||
let events = $state<AuditEvent[]>([]); let auditLoading = $state(true); let auditError = $state('');
|
||||
const roles: { value: UserRole; label: string; description: string }[] = [
|
||||
{ value: 'viewer', label: 'Viewer', description: 'Read fleet status and operation summaries.' },
|
||||
{ value: 'operator', label: 'Operator', description: 'Operate servers, campaigns, scripts, and configs.' },
|
||||
{ value: 'admin', label: 'Administrator', description: 'Manage users, access, settings, hosts, and the fleet.' }
|
||||
];
|
||||
const roleDescription = $derived(roles.find(item => item.value === role)?.description);
|
||||
const administrator = $derived($page.data.user?.role === 'admin');
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch('/api/users'); const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not load users.');
|
||||
users = body.users;
|
||||
} catch (cause) { error = (cause as Error).message; }
|
||||
finally { loading = false; }
|
||||
}
|
||||
async function loadAudit() {
|
||||
auditLoading = true; auditError = '';
|
||||
try {
|
||||
const response = await fetch('/api/users/audit'); const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not load account activity.');
|
||||
events = body.events;
|
||||
} catch (cause) { auditError = (cause as Error).message; }
|
||||
finally { auditLoading = false; }
|
||||
}
|
||||
const actionLabels: Record<string, string> = { setup: 'Administrator created', login: 'Signed in', 'login.failed': 'Sign-in failed', logout: 'Signed out', 'sessions.revoked': 'Sessions ended', 'user.created': 'User created', 'user.updated': 'Access updated', 'password.changed': 'Password changed' };
|
||||
function auditDetails(value: string): string { try { const parsed = JSON.parse(value); return Object.keys(parsed).length ? JSON.stringify(parsed, null, 2) : ''; } catch { return value; } }
|
||||
onMount(() => { if (administrator) void Promise.all([load(), loadAudit()]); else { loading = false; auditLoading = false; } });
|
||||
function openCreate() {
|
||||
username = ''; displayName = ''; password = ''; role = 'viewer'; enabled = true; showPassword = false;
|
||||
formError = ''; editing = null; createOpen = true;
|
||||
}
|
||||
function openEdit(user: AuthUser) {
|
||||
editing = user; username = user.username; displayName = user.displayName; password = ''; role = user.role;
|
||||
enabled = !user.disabled; showPassword = false; formError = ''; editOpen = true;
|
||||
}
|
||||
async function create(event: SubmitEvent) {
|
||||
event.preventDefault(); if (busy) return; busy = true; formError = ''; error = ''; notice = '';
|
||||
try {
|
||||
const response = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, displayName, password, role }) });
|
||||
const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not create this user.');
|
||||
password = ''; createOpen = false; notice = `${body.user.displayName || body.user.username} was added. They must change the temporary password at sign-in.`;
|
||||
await Promise.all([load(), loadAudit()]);
|
||||
} catch (cause) { formError = (cause as Error).message; } finally { busy = false; }
|
||||
}
|
||||
async function save(event: SubmitEvent) {
|
||||
event.preventDefault(); if (busy || !editing) return; busy = true; formError = ''; error = ''; notice = '';
|
||||
const ownAccessChanged = editing.id === $page.data.user?.id && (role !== editing.role || enabled === editing.disabled || !!password);
|
||||
try {
|
||||
const response = await fetch(`/api/users/${encodeURIComponent(editing.id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ displayName, role, disabled: !enabled, ...(password ? { password } : {}) }) });
|
||||
const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not save user changes.');
|
||||
password = ''; editOpen = false;
|
||||
if (ownAccessChanged) { window.location.assign('/login'); return; }
|
||||
notice = `Changes saved for ${body.user.displayName || body.user.username}.`; await Promise.all([load(), loadAudit()]); await invalidateAll();
|
||||
} catch (cause) { formError = (cause as Error).message; } finally { busy = false; }
|
||||
}
|
||||
async function revoke(user: AuthUser) {
|
||||
const own = user.id === $page.data.user?.id;
|
||||
if (!await confirmAction({ title: `Sign out ${user.displayName || user.username}?`, message: own ? 'This will end all of your sessions, including this one. You can sign in again with your password.' : 'This ends all active sessions for this user. Their account and password remain available for a new sign-in.', confirmLabel: 'Sign out sessions', danger: true })) return;
|
||||
busy = true; error = ''; notice = '';
|
||||
try {
|
||||
const response = await fetch(`/api/users/${encodeURIComponent(user.id)}/revoke-sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||||
const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not end these sessions.');
|
||||
if (own) { window.location.assign('/login'); return; }
|
||||
notice = `All sessions ended for ${user.displayName || user.username}.`;
|
||||
await loadAudit();
|
||||
} catch (cause) { error = (cause as Error).message; } finally { busy = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Users · Electric Cherry</title></svelte:head>
|
||||
<section class="page">
|
||||
<SectionHeader title="Users" subtitle="Create accounts and manage who can operate your fleet.">
|
||||
{#snippet actions()}{#if administrator}<Button variant="primary" disabled={busy} onclick={openCreate}>Add user</Button>{/if}{/snippet}
|
||||
</SectionHeader>
|
||||
{#if !administrator}<p class="error" role="alert">Only administrators can manage users.</p><a href="/account">Go to your account</a>
|
||||
{:else}
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}{#if notice}<p class="notice" role="status">{notice}</p>{/if}
|
||||
<div class="roles" aria-label="Access roles">{#each roles as item}<div><h2>{item.label}</h2><p>{item.description}</p></div>{/each}</div>
|
||||
{#if loading}<p class="muted" role="status">Loading users…</p>
|
||||
{:else if !users.length}<section class="panel"><p>No users could be loaded.</p><Button onclick={load}>Try again</Button></section>
|
||||
{:else}
|
||||
<div class="user-list" aria-label="User accounts">{#each users as user (user.id)}<article class="user-row">
|
||||
<div class="user-name"><h2>{user.displayName} {#if user.id === $page.data.user?.id}<span class="you">You</span>{/if}</h2><p>@{user.username}</p></div>
|
||||
<div class="access"><span class="role">{user.role === 'admin' ? 'Administrator' : user.role}</span><span class:disabled={user.disabled}>{user.disabled ? 'Disabled' : 'Enabled'}</span>{#if user.mustChangePassword && !user.disabled}<small>Password change required</small>{/if}</div>
|
||||
<div class="last-login"><span>Last sign-in</span><p>{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : 'Never'}</p></div>
|
||||
<div class="actions"><Button size="sm" disabled={busy} onclick={() => openEdit(user)}>Edit access</Button><Button size="sm" variant="ghost" disabled={busy} onclick={() => revoke(user)}>Sign out sessions</Button></div>
|
||||
</article>{/each}</div>
|
||||
{/if}
|
||||
<section class="panel audit" aria-labelledby="audit-title">
|
||||
<div class="audit-heading"><div><h2 id="audit-title">Account activity</h2><p class="muted">The latest 200 sign-in and account events.</p></div><Button size="sm" variant="ghost" disabled={auditLoading} onclick={loadAudit}>Refresh activity</Button></div>
|
||||
{#if auditError}<p class="error" role="alert">{auditError}</p>{/if}
|
||||
{#if auditLoading && !events.length}<p class="muted" role="status">Loading account activity…</p>
|
||||
{:else if !events.length}<p class="muted">No account events recorded.</p>
|
||||
{:else}<div class="table-wrap"><table><thead><tr><th>Time</th><th>Event</th><th>By</th><th>Account</th><th>Details</th></tr></thead><tbody>
|
||||
{#each events as event (event.id)}{@const details = auditDetails(event.details)}<tr><td class="event-time">{new Date(event.createdAt).toLocaleString()}</td><td>{actionLabels[event.action] ?? event.action}</td><td>{event.actor ? `@${event.actor}` : 'Unauthenticated'}</td><td>{event.targetId ? users.find(user => user.id === event.targetId)?.username ?? event.targetId : '—'}</td><td>{#if details}<details><summary>View details</summary><pre>{details}</pre></details>{:else}—{/if}</td></tr>{/each}
|
||||
</tbody></table></div>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<Dialog bind:open={createOpen} title="Add user" description="Share the temporary password privately. The user must choose a new password when they first sign in." {busy}>
|
||||
<form onsubmit={create} aria-busy={busy}><fieldset disabled={busy}>
|
||||
{#if formError}<p class="error" role="alert">{formError}</p>{/if}
|
||||
<label for="new-username">Username<input id="new-username" name="username" required minlength="3" maxlength="64" pattern={'[a-zA-Z0-9][a-zA-Z0-9_.\\-]{2,63}'} autocomplete="off" autocapitalize="none" spellcheck="false" bind:value={username} aria-describedby="new-username-help" /></label>
|
||||
<p class="hint" id="new-username-help">3–64 characters: letters, numbers, periods, underscores, and hyphens. Start with a letter or number.</p>
|
||||
<label for="new-name">Display name<input id="new-name" name="displayName" required maxlength="100" autocomplete="off" bind:value={displayName} /></label>
|
||||
<label for="new-role">Role<select id="new-role" name="role" bind:value={role} aria-describedby="new-role-help">{#each roles as item}<option value={item.value}>{item.label}</option>{/each}</select></label><p class="hint" id="new-role-help">{roleDescription}</p>
|
||||
<label for="temporary-password">Temporary password<input id="temporary-password" name="password" type={showPassword ? 'text' : 'password'} required minlength="15" maxlength="128" autocomplete="new-password" bind:value={password} aria-describedby="temporary-help" /></label>
|
||||
<p class="hint" id="temporary-help">15–128 characters. Save it before adding the user; it will not be displayed again.</p>
|
||||
<label class="checkbox"><input type="checkbox" bind:checked={showPassword} />Show temporary password</label>
|
||||
<div class="form-actions"><Button disabled={busy} variant="ghost" onclick={() => { createOpen = false; password = ''; }}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy}>Add user</Button></div>
|
||||
</fieldset></form>
|
||||
</Dialog>
|
||||
|
||||
<Dialog bind:open={editOpen} title={`Edit ${editing?.username ?? 'user'}`} description="Update access or set a new temporary password." {busy}>
|
||||
<form onsubmit={save} aria-busy={busy}><fieldset disabled={busy}>
|
||||
{#if formError}<p class="error" role="alert">{formError}</p>{/if}
|
||||
<label for="edit-name">Display name<input id="edit-name" name="displayName" required maxlength="100" autocomplete="off" bind:value={displayName} /></label>
|
||||
<label for="edit-role">Role<select id="edit-role" name="role" bind:value={role} aria-describedby="edit-role-help">{#each roles as item}<option value={item.value}>{item.label}</option>{/each}</select></label><p class="hint" id="edit-role-help">{roleDescription}</p>
|
||||
<label class="checkbox"><input type="checkbox" bind:checked={enabled} />Account enabled</label><p class="hint">Disabled users cannot sign in. Running game servers continue operating.</p>
|
||||
{#if editing?.id === $page.data.user?.id}<p class="hint">Use <a href="/account">My account</a> to change your own password. Changes to your access can sign you out.</p>
|
||||
{:else}<label for="reset-password">New temporary password <span class="optional">(optional)</span><input id="reset-password" name="password" type={showPassword ? 'text' : 'password'} minlength="15" maxlength="128" autocomplete="new-password" bind:value={password} aria-describedby="reset-help" /></label><p class="hint" id="reset-help">Leave blank to keep the current password. A reset requires the user to choose a new password at their next sign-in.</p><label class="checkbox"><input type="checkbox" bind:checked={showPassword} />Show temporary password</label>{/if}
|
||||
<div class="form-actions"><Button disabled={busy} variant="ghost" onclick={() => { editOpen = false; password = ''; }}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy}>Save changes</Button></div>
|
||||
</fieldset></form>
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
.page { display:grid; gap:24px; } .panel,.roles,.user-list { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); }
|
||||
.roles { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:24px; padding:24px; } h2 { margin:0; font-size:var(--text-md); } .roles p,.muted { color:var(--text-secondary); font-size:var(--text-sm); line-height:1.6; } .roles p { margin:8px 0 0; } .panel { padding:24px; }
|
||||
.user-row { display:grid; grid-template-columns:minmax(180px,1.3fr) minmax(120px,.8fr) minmax(140px,1fr) auto; gap:24px; align-items:center; padding:24px; } .user-row+.user-row { border-top:1px solid var(--border-subtle); } .user-name p { color:var(--text-secondary); font-size:var(--text-sm); margin:6px 0 0; } .you { padding:3px 7px; margin-left:6px; background:var(--surface-raised); border-radius:var(--radius-sm); color:var(--text-secondary); font-size:var(--text-xs); font-weight:normal; }
|
||||
.access { display:grid; gap:5px; font-size:var(--text-sm); } .access .role { color:var(--text-primary); text-transform:capitalize; } .access span:not(.role),.access small { color:var(--text-secondary); font-size:var(--text-xs); } .access span.disabled { color:var(--state-danger); } .last-login>span { font-size:var(--text-xs); color:var(--text-secondary); } .last-login p { font-size:var(--text-sm); margin:5px 0 0; } .actions { display:flex; gap:8px; flex-wrap:wrap; justify-content:end; }
|
||||
.audit { min-width:0; } .audit-heading { display:flex; gap:16px; align-items:start; justify-content:space-between; flex-wrap:wrap; } .audit-heading p { margin:6px 0 20px; } .table-wrap { overflow-x:auto; } table { width:100%; border-collapse:collapse; text-align:left; font-size:var(--text-sm); } th,td { padding:12px; border-bottom:1px solid var(--border-subtle); vertical-align:top; } th { color:var(--text-secondary); font-weight:500; } td { overflow-wrap:anywhere; } .event-time { min-width:165px; } details { min-width:120px; } summary { cursor:pointer; color:var(--accent-primary-hover); } pre { white-space:pre-wrap; font-size:var(--text-xs); max-width:320px; }
|
||||
fieldset { display:grid; gap:16px; border:0; padding:0; margin:0; min-width:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input:not([type='checkbox']),select { width:100%; padding:11px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-md); font:inherit; } .checkbox { display:flex; align-items:center; gap:10px; } .checkbox input { width:18px; height:18px; accent-color:var(--accent-primary); } .hint { font-size:var(--text-xs); color:var(--text-secondary); line-height:1.6; margin:-8px 0 0; } .optional { color:var(--text-secondary); } .form-actions { display:flex; gap:12px; justify-content:end; margin-top:8px; } .error { color:var(--state-danger); font-size:var(--text-sm); line-height:1.6; margin:0; } .notice { color:var(--state-online); font-size:var(--text-sm); margin:0; }
|
||||
@media(max-width:1100px) { .user-row { grid-template-columns:minmax(140px,1.3fr) minmax(100px,1fr) minmax(140px,1fr); } .actions { grid-column:1/-1; justify-content:start; } }
|
||||
@media(max-width:640px) { .roles { grid-template-columns:1fr; gap:20px; } .user-row { grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:20px; padding:20px; } .user-name { grid-column:1/-1; } .roles,.panel { padding:20px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
let token = $state(''); let username = $state(''); let displayName = $state('');
|
||||
let password = $state(''); let confirmPassword = $state(''); let busy = $state(false); let error = $state('');
|
||||
async function setup(event: SubmitEvent) {
|
||||
event.preventDefault(); if (busy) return; error = '';
|
||||
if (password !== confirmPassword) { error = 'The passwords do not match.'; return; }
|
||||
busy = true;
|
||||
try {
|
||||
const response = await fetch('/api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, username, displayName, password }) });
|
||||
const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not create the administrator.');
|
||||
token = ''; password = ''; confirmPassword = '';
|
||||
window.location.assign(body.user?.mustChangePassword ? '/account' : '/');
|
||||
} catch (cause) { error = (cause as Error).message; busy = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Initial setup · Electric Cherry</title><meta name="robots" content="noindex" /></svelte:head>
|
||||
<section class="auth-card">
|
||||
<SectionHeader title="Create the first administrator" subtitle="Secure this controller and invite your team." />
|
||||
<p class="help">Use the setup token configured by the controller owner. This step is available only until the first administrator is created.</p>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
<form onsubmit={setup} aria-busy={busy}><fieldset disabled={busy}>
|
||||
<label for="setup-token">Setup token<input id="setup-token" name="token" type="password" required minlength="32" autocomplete="off" spellcheck="false" bind:value={token} aria-describedby="token-help" /></label>
|
||||
<p id="token-help" class="hint">Provided through the controller’s <code>EC_SETUP_TOKEN</code> setting.</p>
|
||||
<label for="setup-username">Username<input id="setup-username" name="username" required minlength="3" maxlength="64" pattern={'[a-zA-Z0-9][a-zA-Z0-9_.\\-]{2,63}'} autocomplete="username" autocapitalize="none" spellcheck="false" bind:value={username} aria-describedby="username-help" /></label>
|
||||
<p id="username-help" class="hint">3–64 characters. Start with a letter or number; periods, underscores, and hyphens are allowed.</p>
|
||||
<label for="setup-name">Display name<input id="setup-name" name="displayName" required maxlength="100" autocomplete="name" bind:value={displayName} /></label>
|
||||
<label for="setup-password">Password<input id="setup-password" name="password" type="password" required minlength="15" maxlength="128" autocomplete="new-password" bind:value={password} aria-describedby="password-help" /></label>
|
||||
<p id="password-help" class="hint">Use 15–128 characters. A long, unique passphrase works well.</p>
|
||||
<label for="setup-confirm">Confirm password<input id="setup-confirm" name="confirmPassword" type="password" required minlength="15" maxlength="128" autocomplete="new-password" bind:value={confirmPassword} /></label>
|
||||
<Button type="submit" variant="primary" disabled={busy} loading={busy}>Create administrator</Button>
|
||||
</fieldset></form>
|
||||
<a class="login-link" href="/login">Already have an account? Sign in</a>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.auth-card { padding:32px; border:1px solid var(--border-strong); border-radius:var(--radius-lg); background:var(--surface); box-shadow:0 18px 50px #0002; }
|
||||
form { margin-top:24px; } fieldset { display:grid; gap:16px; border:0; margin:0; padding:0; min-width:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; padding:12px; background:var(--surface-sunken); color:var(--text-primary); border:1px solid var(--border-control); border-radius:var(--radius-md); font:inherit; }
|
||||
.help,.hint,.login-link { font-size:var(--text-sm); color:var(--text-secondary); line-height:1.6; } .help { margin:20px 0 0; } .hint { margin:-8px 0 0; font-size:var(--text-xs); } .login-link { display:block; margin-top:24px; color:var(--accent-primary-hover); }
|
||||
.error { color:var(--state-danger); font-size:var(--text-sm); padding:12px; border:1px solid var(--state-danger); border-radius:var(--radius-md); overflow-wrap:anywhere; }
|
||||
@media(max-width:540px) { .auth-card { padding:24px; } }
|
||||
</style>
|
||||
Reference in New Issue
Block a user