diff --git a/docs/authentication.md b/docs/authentication.md index d1fca9d..492739c 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -31,7 +31,7 @@ guessable token. An unset setup token holds setup closed. | Raw configs, file diffs, logs and telemetry diagnostics | No | Yes | Yes | | Deploy/start/stop/restart/rebuild/remove servers | No | Yes | Yes | | Scripts, plugins, configs, campaigns, betas and collections | Read summaries | Manage | Manage | -| Host setup, enrollment, credentials, runtime preparation and game-file upload/cache | Read status | Read status | Manage | +| Host setup, deletion, enrollment, credentials, runtime preparation and game-file upload/cache | Read status | Read status | Manage | | Server keys and global settings/maintenance policies | No | No | Manage | | Create users, change roles, disable accounts and reset passwords | No | No | Yes | | Own password and sign out | Yes | Yes | Yes | diff --git a/docs/native-hosts.md b/docs/native-hosts.md index bc4e74c..4bfba8b 100644 --- a/docs/native-hosts.md +++ b/docs/native-hosts.md @@ -70,6 +70,9 @@ explicit installer `--reenroll` when local credentials already exist. API outline: +- `GET /api/hosts/:id`: host details, operation history and deletion blockers. +- `DELETE /api/hosts/:id`: administrator removes an unused host and revokes access; + returns `409` if servers, reservations or unresolved delivered operations remain. - `POST /api/agent/enroll`: consume enrollment token and return host credentials. - `POST /api/agent/heartbeat`: authenticated inventory, current operation and caches. - `GET /api/agent/operations`: deliver/reconcile durable operation IDs. @@ -123,6 +126,18 @@ may still join until an eligible empty moment; draining does not kick them. **Disable** holds agent dispatch. Neither mode, controller downtime nor agent communication loss stops unrelated running containers. +Administrators can choose **Delete host** on a host card or at the top of its +detail page. The confirmation lists anything that must be resolved first: +registered servers, reserved ports, or operations already sent to the agent. +Drain the host or remove its servers, then let outstanding operations reconcile. +An unused host can be deleted before enrollment or while offline. + +Deleting a host removes it from the controller's host list and deployment +choices, revokes its credentials, and invalidates unused installer links. +Queued commands that have never been sent to the agent are cancelled. Operation +history and published libraries are retained; the agent and game files remain on +the machine. Adding the machine again requires a new host enrollment. + ## Libraries and ports Choose the actual game folder, not its parent, a ZIP archive or the Plutonium diff --git a/src/lib/hosts.ts b/src/lib/hosts.ts index 6ead592..49140e9 100644 --- a/src/lib/hosts.ts +++ b/src/lib/hosts.ts @@ -2,6 +2,8 @@ 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 HostDeletion { allowed: boolean; blockers: string[] } + 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; diff --git a/src/lib/server/hosts/index.ts b/src/lib/server/hosts/index.ts index 97e7da2..d58dd9c 100644 --- a/src/lib/server/hosts/index.ts +++ b/src/lib/server/hosts/index.ts @@ -2,7 +2,7 @@ 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'; +import type { Host, HostDeletion, HostInventory, HostMode, HostOperation, HostOperationState } from '../../hosts.js'; export const AGENT_PROTOCOL_VERSION = 1; export const HEARTBEAT_MAX_AGE_MS = 90_000; @@ -33,12 +33,57 @@ function hostFromRow(row: HostRow): Host { 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 listHosts(): Host[] { return (database().prepare(hostSql + ' WHERE h.deleted_at IS NULL 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; + const row = database().prepare(hostSql + ' WHERE h.id = ? AND h.deleted_at IS NULL').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; } + +export class HostDeletionError extends Error { + constructor(message: string, readonly status: 404 | 409, readonly blockers: string[] = []) { super(message); } +} + +/** Controller records can be removed only after all remote instance ownership is resolved. */ +export function hostDeletionStatus(id: string): HostDeletion { + if (!getHost(id)) throw new HostDeletionError('Host not found.', 404); + const handle = database(); + const blockers: string[] = []; + const instances = handle.prepare('SELECT server FROM native_instances WHERE host_id=? AND retired_at IS NULL ORDER BY server').all(id) as { server: string }[]; + if (instances.length) { + const names = instances.slice(0, 5).map(instance => instance.server).join(', ') + (instances.length > 5 ? ', …' : ''); + blockers.push(`Remove the ${instances.length} server instance${instances.length === 1 ? '' : 's'} assigned to this host first (${names}). Queued and failed deployments also require removal.`); + } + const ports = handle.prepare('SELECT COUNT(*) AS count FROM ec_host_ports WHERE host_id=?').get(id) as { count: number }; + if (ports.count && !instances.length) blockers.push(`This host still has ${ports.count} reserved game port${ports.count === 1 ? '' : 's'}. Finish or reconcile the corresponding server removals first.`); + // A pending command may have reached the agent before its acknowledgement. + // Only commands that have never been delivered are safe to cancel locally. + const operations = handle.prepare(`SELECT kind,state FROM ec_host_operations WHERE host_id=? + AND (state IN ('accepted','running','unknown') OR (state='pending' AND last_delivered_at IS NOT NULL)) + ORDER BY created_at`).all(id) as { kind: string; state: string }[]; + if (operations.length) { + const work = operations.slice(0, 5).map(operation => `${operation.kind}: ${operation.state}`).join(', ') + (operations.length > 5 ? ', …' : ''); + blockers.push(`${operations.length} delivered operation${operations.length === 1 ? ' is' : 's are'} unresolved (${work}). Let the agent reconnect and finish or reconcile this work before deleting the host.`); + } + return { allowed: blockers.length === 0, blockers }; +} + +/** Retain references and audit history; deletion never sends remote cleanup commands. */ +export function deleteHost(id: string): void { + const handle = database(); + handle.transaction(() => { + const status = hostDeletionStatus(id); + if (!status.allowed) throw new HostDeletionError(status.blockers.join(' '), 409, status.blockers); + const timestamp = now(); + const reason = 'Host deleted before this operation was delivered to the agent.'; + handle.prepare(`INSERT INTO ec_host_operation_events(operation_id,state,created_at,error) + SELECT id,'failed',?,? FROM ec_host_operations WHERE host_id=? AND state='pending' AND last_delivered_at IS NULL`).run(timestamp, reason, id); + handle.prepare("UPDATE ec_host_operations SET state='failed',error=?,updated_at=? WHERE host_id=? AND state='pending' AND last_delivered_at IS NULL").run(reason, timestamp, id); + handle.prepare('UPDATE ec_host_credentials SET revoked_at=? WHERE host_id=? AND revoked_at IS NULL').run(timestamp, id); + handle.prepare('UPDATE ec_host_enrollments SET consumed_at=? WHERE host_id=? AND consumed_at IS NULL').run(timestamp, id); + handle.prepare("UPDATE ec_hosts SET deleted_at=?,mode='disabled' WHERE id=? AND deleted_at IS NULL").run(timestamp, id); + }).immediate(); +} 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(); @@ -195,6 +240,7 @@ export function pendingHostOperations(hostId: string): HostOperation[] { } export function reportHostOperation(hostId: string, id: string, input: { state: unknown; result?: unknown; error?: unknown }): HostOperation { return database().transaction(() => { + requireHost(hostId); 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.'); diff --git a/src/lib/server/hosts/schema.ts b/src/lib/server/hosts/schema.ts index cf9e1b0..6abb3ab 100644 --- a/src/lib/server/hosts/schema.ts +++ b/src/lib/server/hosts/schema.ts @@ -10,7 +10,7 @@ export function ensureHostSchema(handle: Database.Database): void { 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 '{}' + created_at TEXT NOT NULL, enrolled_at TEXT, last_heartbeat TEXT, inventory TEXT NOT NULL DEFAULT '{}', deleted_at TEXT ); 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, @@ -43,5 +43,7 @@ export function ensureHostSchema(handle: Database.Database): void { `); 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'); + const hostColumns = handle.prepare('PRAGMA table_info(ec_hosts)').all() as { name: string }[]; + if (!hostColumns.some(column => column.name === 'deleted_at')) handle.exec('ALTER TABLE ec_hosts ADD COLUMN deleted_at TEXT'); initialized.add(handle); } diff --git a/src/lib/ui/components/DeleteHostDialog.svelte b/src/lib/ui/components/DeleteHostDialog.svelte new file mode 100644 index 0000000..4703575 --- /dev/null +++ b/src/lib/ui/components/DeleteHostDialog.svelte @@ -0,0 +1,65 @@ + + + +
+

The agent and game files stay on the machine. Published game libraries and operation history are kept. Commands that have never been sent to the host will be cancelled.

+ {#if checking}

Checking servers and unfinished operations…

+ {:else if deletion && !deletion.allowed}
This host still has work to finish:
    {#each deletion.blockers as blocker}
  • {blocker}
  • {/each}

Open open = false}>host details to review its operations or drain its servers.

+ {:else if deletion?.allowed}

This host is ready to delete. Adding it again requires a new enrollment.

{/if} + {#if error}{/if} +
+ {#snippet actions()} + + {#if !deletion?.allowed}{/if} + + {/snippet} +
+ + diff --git a/src/routes/api/hosts/[id]/+server.ts b/src/routes/api/hosts/[id]/+server.ts index d2bfaac..7e7523c 100644 --- a/src/routes/api/hosts/[id]/+server.ts +++ b/src/routes/api/hosts/[id]/+server.ts @@ -1,14 +1,23 @@ import { error, json } from '@sveltejs/kit'; -import { getHost, listHostOperations, updateHost } from '$lib/server/hosts/index.js'; +import { deleteHost, getHost, hostDeletionStatus, HostDeletionError, 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) }); + return json({ host, deletion: hostDeletionStatus(host.id), 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); } } + +export function DELETE({ request, params }: Event) { + requireManagementOrigin(request); + try { deleteHost(params.id); return json({ deleted: true }); } + catch (cause) { + if (cause instanceof HostDeletionError) return json({ error: cause.message, blockers: cause.blockers }, { status: cause.status }); + return hostApiError(cause); + } +} diff --git a/src/routes/hosts/+page.svelte b/src/routes/hosts/+page.svelte index 9c871e3..9bbb8e3 100644 --- a/src/routes/hosts/+page.svelte +++ b/src/routes/hosts/+page.svelte @@ -4,14 +4,24 @@ import type { Host } from '$lib/hosts.js'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import Button from '$ui/components/primitives/Button.svelte'; + import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte'; let hosts = $state([]); let loading = $state(true); let busy = $state(false); let error = $state(''); + let notice = $state(''); let deleteOpen = $state(false); let deletingHost = $state(null); + let loadSequence = 0; 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; } + const sequence = ++loadSequence; + try { const response = await fetch('/api/hosts'); const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not load hosts.'); if (sequence === loadSequence) { hosts = body.hosts; error = ''; } } + catch (cause) { if (sequence === loadSequence) error = (cause as Error).message; } finally { if (sequence === loadSequence) loading = false; } + } + async function deleted() { + notice = `${deletingHost?.name ?? 'Host'} deleted.`; + if (enrollment?.hostId === deletingHost?.id) enrollment = null; + deletingHost = null; + await load(); } async function add(event: SubmitEvent) { event.preventDefault(); busy = true; error = ''; enrollment = null; @@ -28,6 +38,7 @@
{#if error}{/if} + {#if notice}

{notice}

{/if} {#if canManage}

Add host

Run the installation command on a Debian 12 or 13 amd64 machine. Each host connects to this controller over HTTPS.

@@ -39,14 +50,15 @@ {#if enrollment}

Install the agent

This single-use command expires {new Date(enrollment.expiresAt).toLocaleString()}. Run it with administrator access on your game host.

{enrollment.command}
Open host setup →
{/if}
{:else}

Administrators manage host setup, runtime preparation, and credentials.

{/if} {#if loading}

Loading hosts…

{:else if !hosts.length}

No hosts connected yet.{canManage ? ' Add a host to begin.' : ''}

{:else} - +
{#each hosts as host (host.id)}

{host.name}

{host.readiness}

{host.hostname || 'Awaiting enrollment'} · {host.connectionState}

{host.inventory.os ?? 'OS pending'} · {host.inventory.architecture ?? 'Architecture pending'}

Game ports {host.portMin}–{host.portMax}

{#if host.readinessReasons.length}

{host.readinessReasons[0]}

{/if}
{#if canManage}{/if}
{/each}
{/if}
+ diff --git a/src/routes/hosts/[id]/+page.svelte b/src/routes/hosts/[id]/+page.svelte index c4ff756..c2f7843 100644 --- a/src/routes/hosts/[id]/+page.svelte +++ b/src/routes/hosts/[id]/+page.svelte @@ -1,11 +1,14 @@ {host?.name ?? 'Host'} · Electric Cherry -
← All hosts +
← All hosts + {#snippet actions()}{#if canManage && host}{/if}{/snippet} + {#if error}{/if}{#if notice}

{notice}

{/if} {#if loading}

Loading host…

{:else if host}

{host.connectionState} · {host.readiness}

Last heartbeat: {host.lastHeartbeat ? new Date(host.lastHeartbeat).toLocaleString() : 'Never'}
@@ -49,6 +54,7 @@

Operation history

{#if !operations.length}

No remote operations yet.

{:else}
{#each operations as operation}{/each}
OperationStateUpdatedResult
{operation.kind}{operation.id}{operation.state}{new Date(operation.updatedAt).toLocaleString()}{operation.error ?? '—'}
{/if}
{/if}
+ goto('/hosts')} />