diff --git a/README.md b/README.md index 84d28ee..375ef88 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,22 @@ directly. No token, no session, no auth freeze. If Komodo or RCON aren't configured, the sweep marks pending rows `manual` so nothing hangs forever. +## Saved server keys + +Use **Server keys** to add, replace, or remove a Plutonium key for an exact +server ID such as `t5_zombie_theater_2`. IDs use a supported map slug and a +lobby number from 1 to 20; the server does not need to be deployed first. +During deployment, choose the map and lobby. A saved key is reused automatically, +and any newly entered key is saved for that ID, including if deployment fails. +Destroying a server preserves its saved key. Updating or removing a saved key +does not change a running server; changes apply to future deployments. + +Keys are stored in the application's SQLite database at `DB_PATH`, independently +of server records. Preserve that database across container replacements. Key +values are not returned by the management API, but are stored unencrypted in +SQLite, so database files and backups contain credentials. Existing deployment +keys are not automatically imported from Komodo; add them once to retain them. + ## Local dev Windows or macOS — no `/root/zombies` on disk, so seed a fixture tree first. diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index 8a21957..0d2c32c 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -238,6 +238,13 @@ CREATE TABLE IF NOT EXISTS deployment_scripts ( ); CREATE INDEX IF NOT EXISTS idx_deployment_scripts_dep ON deployment_scripts(deployment_id); +-- Deliberately independent of servers: credentials survive server destruction. +CREATE TABLE IF NOT EXISTS server_keys ( + server_id TEXT PRIMARY KEY, + pluto_key TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL diff --git a/src/lib/server/fleet/create.ts b/src/lib/server/fleet/create.ts index 3fe1677..ce10bed 100644 --- a/src/lib/server/fleet/create.ts +++ b/src/lib/server/fleet/create.ts @@ -2,6 +2,7 @@ import { mkdir, chmod, rm, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { config, destOfKind } from '../config.js'; import { db } from '../db/index.js'; +import { getServerKey, saveServerKey } from './server-keys.js'; import { getServer } from './servers.js'; import { t5ZombieMapBySlug, type GameMode } from './map-aliases.js'; import { @@ -26,6 +27,7 @@ const T5_ZOMBIE_CONFIG_FILE = config.configTargetFile; const T5_ZOMBIE_GAME_ID = 't5sp'; export type CreateServerFailure = + | 'bad-request' | 'unconfigured' // Komodo creds / target server missing | 'unknown-map' // slug isn't in the catalog | 'no-slot' // all lobby slots for this map are taken @@ -45,7 +47,8 @@ export type DestroyServerResult = export interface CreateServerInput { gameMode: GameMode; // 'zombie' — only value supported today mapSlug: string; // 'cosmodrome' - plutoKey: string; // required; never persisted + plutoKey?: string; // saved for this server ID when supplied + lobby?: number; // explicit stable identity; otherwise first available createdBy?: string; // 'ui' by default } @@ -132,7 +135,15 @@ function buildSpec(args: { }; } +let creating = false; export async function createServer(input: CreateServerInput): Promise { + if (creating) return { ok: false, kind: 'no-slot', message: 'Another server is being created. Please retry shortly.' }; + creating = true; + try { return await createServerUnlocked(input); } + finally { creating = false; } +} + +async function createServerUnlocked(input: CreateServerInput): Promise { if (input.gameMode !== 'zombie') { return { ok: false, kind: 'unknown-map', message: `unsupported game mode: ${input.gameMode}` }; } @@ -149,11 +160,17 @@ export async function createServer(input: CreateServerInput): Promise 20)) { + return { ok: false, kind: 'bad-request', message: 'Lobby must be an integer from 1 to 20' }; + } + const lobby = input.lobby ?? nextLobbyNumber(baseName); if (lobby === null) { return { ok: false, kind: 'no-slot', message: `all lobby slots (1..20) for ${baseName} are taken` }; } const name = `${baseName}_${lobby}`; + if (getServer(name)) return { ok: false, kind: 'no-slot', message: `${name} is already deployed` }; + const plutoKey = input.plutoKey?.trim() || getServerKey(name); + if (!plutoKey) return { ok: false, kind: 'bad-request', message: `No saved key for ${name}. Enter a Plutonium server key.` }; const port = nextAvailablePort(); if (port === null) { @@ -164,6 +181,10 @@ export async function createServer(input: CreateServerInput): Promise {}); diff --git a/src/lib/server/fleet/server-keys.ts b/src/lib/server/fleet/server-keys.ts new file mode 100644 index 0000000..137ab94 --- /dev/null +++ b/src/lib/server/fleet/server-keys.ts @@ -0,0 +1,35 @@ +import { db } from '../db/index.js'; +import { t5ZombieMapBySlug } from './map-aliases.js'; + +export function validServerId(value: unknown): value is string { + if (typeof value !== 'string') return false; + const match = /^t5_zombie_([a-z0-9]+)_([1-9]|1[0-9]|20)$/.exec(value); + return !!match && !!t5ZombieMapBySlug(match[1]); +} + +export function listServerKeys() { + return db().prepare(`SELECT k.server_id, k.updated_at, + EXISTS(SELECT 1 FROM servers s WHERE s.name = k.server_id) AS deployed + FROM server_keys k ORDER BY k.server_id`).all() as Array<{ + server_id: string; updated_at: string; deployed: number; + }>; +} + +export function getServerKey(serverId: string): string | undefined { + return (db().prepare('SELECT pluto_key FROM server_keys WHERE server_id = ?') + .get(serverId) as { pluto_key: string } | undefined)?.pluto_key; +} + +export function saveServerKey(serverId: unknown, key: unknown): void { + if (!validServerId(serverId)) throw new Error('Choose a valid T5 zombie server ID with a lobby number from 1 to 20'); + if (typeof key !== 'string' || !key.trim() || key.trim().length > 512 || /\s/.test(key.trim())) { + throw new Error('Enter a server key without whitespace (maximum 512 characters)'); + } + db().prepare(`INSERT INTO server_keys (server_id, pluto_key) VALUES (?, ?) + ON CONFLICT(server_id) DO UPDATE SET pluto_key = excluded.pluto_key, updated_at = datetime('now')`) + .run(serverId, key.trim()); +} + +export function removeServerKey(serverId: string): boolean { + return db().prepare('DELETE FROM server_keys WHERE server_id = ?').run(serverId).changes > 0; +} diff --git a/src/lib/ui/components/DeployServerDialog.svelte b/src/lib/ui/components/DeployServerDialog.svelte index 77901d7..4957c3d 100644 --- a/src/lib/ui/components/DeployServerDialog.svelte +++ b/src/lib/ui/components/DeployServerDialog.svelte @@ -22,6 +22,10 @@ let gameMode = $state<'zombie'>('zombie'); let mapSlug = $state(''); let plutoKey = $state(''); + let lobby = $state('1'); + let savedIds = $state([]); + let deployedIds = $state([]); + let keysLoaded = $state(false); let deploying = $state(false); let flash = $state(null); @@ -52,10 +56,21 @@ function reset(): void { plutoKey = ''; + void loadKeys(); flash = null; deploying = false; if (maps.length > 0 && !maps.some((m) => m.slug === mapSlug)) mapSlug = maps[0].slug; } + async function loadKeys() { + keysLoaded = false; + try { + const [keysRes, serversRes] = await Promise.all([fetch('/api/server-keys'), fetch('/api/servers')]); + if (!keysRes.ok || !serversRes.ok) throw new Error('Could not load saved keys and server slots. Reopen this dialog to retry.'); + savedIds = (await keysRes.json()).keys.map((k: { server_id: string }) => k.server_id); + deployedIds = (await serversRes.json()).servers.map((s: { name: string }) => s.name); + keysLoaded = true; + } catch (err) { flash = (err as Error).message; } + } function close(): void { open = false; } function onDialogClick(e: MouseEvent): void { @@ -63,7 +78,21 @@ } let selectedMap = $derived(maps.find((m) => m.slug === mapSlug)); - let previewName = $derived(selectedMap ? `t5_${gameMode}_${selectedMap.slug}_?` : ''); + let previewName = $derived(selectedMap ? `t5_${gameMode}_${selectedMap.slug}_${lobby}` : ''); + + let hasSavedKey = $derived(savedIds.includes(previewName)); + let alreadyDeployed = $derived(deployedIds.includes(previewName)); + let lobbyOptions = $derived(Array.from({ length: 20 }, (_, i) => { + const value = String(i + 1); + const id = `t5_${gameMode}_${mapSlug}_${value}`; + return { value, label: `${value}${deployedIds.includes(id) ? ' (deployed)' : savedIds.includes(id) ? ' (saved key)' : ''}` }; + })); + $effect(() => { + const prefix = `t5_${gameMode}_${mapSlug}_`; + lobby = String(Array.from({ length: 20 }, (_, i) => i + 1) + .find(n => !deployedIds.includes(`${prefix}${n}`)) ?? 1); + }); + $effect(() => { mapSlug; lobby; plutoKey = ''; }); let modeOptions = [{ value: 'zombie', label: 'Zombies' }]; let mapOptions = $derived(maps.map((m) => ({ value: m.slug, label: m.friendly }))); @@ -72,14 +101,14 @@ e.preventDefault(); if (deploying) return; if (!selectedMap) { flash = 'Pick a map'; return; } - if (plutoKey.trim().length === 0) { flash = 'Pluto server key is required'; return; } + if (!hasSavedKey && plutoKey.trim().length === 0) { flash = 'Pluto server key is required'; return; } deploying = true; flash = null; try { const res = await fetch('/api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ gameMode, mapSlug: selectedMap.slug, plutoKey }) + body: JSON.stringify({ gameMode, mapSlug: selectedMap.slug, lobby: Number(lobby), plutoKey: plutoKey.trim() || undefined }) }); const body = (await res.json()) as { ok: boolean; name?: string; port?: number; error?: string; kind?: string }; if (!body.ok || !body.name) { @@ -121,11 +150,15 @@ + + + @@ -135,15 +168,16 @@
Will create {previewName || '—'} - (lobby number + port auto-assigned) + {alreadyDeployed ? "Already deployed; choose another lobby" : "Port auto-assigned"}
+ Manage saved keys
+ {#if editing}{/if} +
+ +{#if error}{/if} +{#if notice}

{notice}

{/if} +

Saved keys

+{#if loading}

Loading keys…

+{:else if keys.length === 0}

No saved keys yet. Add one above or save one when deploying a server.

+{:else} +
+ + {#each keys as key (key.server_id)} + + + + + + + {/each} +
Server IDStatusKeyActions
{key.server_id}{key.deployed ? 'Deployed' : 'Not deployed'}••••••••
+ + +
+{/if} + + diff --git a/tests/unit/create-server.test.ts b/tests/unit/create-server.test.ts index 49ccc78..80cf42d 100644 --- a/tests/unit/create-server.test.ts +++ b/tests/unit/create-server.test.ts @@ -118,3 +118,50 @@ describe('server creation map selection', () => { expect(komodoCreateDeployment).not.toHaveBeenCalled(); }); }); + + +describe('persistent server keys', () => { + it('saves a key, retains it through destruction, and reuses it for the exact lobby', async () => { + const { createServer, destroyServer } = await import('../../src/lib/server/fleet/create.js'); + const { getServerKey, listServerKeys } = await import('../../src/lib/server/fleet/server-keys.js'); + const { komodoCreateDeployment, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js'); + vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: false, kind: 'not-found', message: 'gone' } as never); + const name = 't5_zombie_cosmodrome_2'; + expect(await createServer({ ...input, lobby: 2 })).toMatchObject({ ok: true, name }); + expect(getServerKey(name)).toBe('test-key'); + expect(await destroyServer(name)).toMatchObject({ ok: true }); + expect(listServerKeys()).toEqual([expect.objectContaining({ server_id: name, deployed: 0 })]); + expect(JSON.stringify(listServerKeys())).not.toContain('test-key'); + expect(await createServer({ gameMode: 'zombie', mapSlug: 'cosmodrome', lobby: 2 })).toMatchObject({ ok: true, name }); + const spec = vi.mocked(komodoCreateDeployment).mock.calls.at(-1)![0]; + expect(spec.environment).toContainEqual({ variable: 'PLUTO_SERVER_KEY', value: 'test-key' }); + expect(await createServer({ gameMode: 'zombie', mapSlug: 'cosmodrome', lobby: 1 })).toMatchObject({ ok: false, kind: 'bad-request' }); + }); + + it('updates and removes keys independently of deployments and survives reopening the database', async () => { + const { saveServerKey, getServerKey, removeServerKey } = await import('../../src/lib/server/fleet/server-keys.js'); + const { closeDb } = await import('../../src/lib/server/db/index.js'); + saveServerKey(serverName, ' first-key '); + closeDb(); + expect(getServerKey(serverName)).toBe('first-key'); + saveServerKey(serverName, 'replacement'); + expect(getServerKey(serverName)).toBe('replacement'); + expect(removeServerKey(serverName)).toBe(true); + expect(getServerKey(serverName)).toBeUndefined(); + expect(() => saveServerKey('t5_zombie_unknown_1', 'key')).toThrow(); + expect(() => saveServerKey(serverName, ' ')).toThrow(); + expect(() => saveServerKey(serverName, 'two words')).toThrow(); + }); + + it('rejects invalid or occupied lobbies before external operations', async () => { + const { createServer } = await import('../../src/lib/server/fleet/create.js'); + const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js'); + for (const lobby of [0, 21, 1.5]) { + expect(await createServer({ ...input, lobby })).toMatchObject({ ok: false, kind: 'bad-request' }); + } + expect(komodoCreateDeployment).not.toHaveBeenCalled(); + expect(await createServer({ ...input, lobby: 2 })).toMatchObject({ ok: true }); + expect(await createServer({ ...input, lobby: 2 })).toMatchObject({ ok: false, kind: 'no-slot' }); + expect(komodoCreateDeployment).toHaveBeenCalledTimes(1); + }); +});