Persist Plutonium keys by server ID and add key management

This commit is contained in:
2026-09-17 15:54:10 -04:00
parent e4505a3be8
commit ae173517b6
10 changed files with 305 additions and 19 deletions
+16
View File
@@ -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.
+7
View File
@@ -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
+24 -3
View File
@@ -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<CreateServerResult> {
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<CreateServerResult> {
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<CreateServ
}
const baseName = `t5_${input.gameMode}_${mapEntry.slug}`;
const lobby = nextLobbyNumber(baseName);
if (input.lobby !== undefined && (!Number.isInteger(input.lobby) || input.lobby < 1 || input.lobby > 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<CreateServ
};
}
// Save before external operations so a failed deployment can also be retried.
try { saveServerKey(name, plutoKey); }
catch (err) { return { ok: false, kind: 'bad-request', message: (err as Error).message }; }
const hostDir = `${config.serversRoot}/${name}`;
const initialConfig = getPromotedServerConfig('t5', mapEntry.canonical);
const mapConfig = getServerMapConfig('t5', mapEntry.canonical)!;
@@ -190,7 +211,7 @@ export async function createServer(input: CreateServerInput): Promise<CreateServ
}
// Step 2: Komodo CreateDeployment.
const spec = buildSpec({ name, port, hostDir, plutoKey: input.plutoKey });
const spec = buildSpec({ name, port, hostDir, plutoKey });
const createRes = await komodoCreateDeployment(spec);
if (!createRes.ok) {
await rm(hostDir, { recursive: true, force: true }).catch(() => {});
+35
View File
@@ -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;
}
@@ -22,6 +22,10 @@
let gameMode = $state<'zombie'>('zombie');
let mapSlug = $state<string>('');
let plutoKey = $state('');
let lobby = $state('1');
let savedIds = $state<string[]>([]);
let deployedIds = $state<string[]>([]);
let keysLoaded = $state(false);
let deploying = $state(false);
let flash = $state<string | null>(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 @@
<Select bind:value={mapSlug} options={mapOptions} disabled={deploying} />
</Field>
<Field label="Pluto server key" hint="Paste each deploy — not stored anywhere.">
<Field label="Lobby number" hint="Choose the same lobby to reuse its server ID and saved key.">
<Select bind:value={lobby} options={lobbyOptions} disabled={deploying} />
</Field>
<Field label="Pluto server key" hint={hasSavedKey ? 'Saved key ready. Leave blank to reuse it, or enter a replacement.' : 'Saved for this server ID and reused on future deployments.'}>
<Input
type="password"
bind:value={plutoKey}
placeholder="paste key"
placeholder={hasSavedKey ? "Saved key ready" : "paste key"}
autocomplete="off"
disabled={deploying}
/>
@@ -135,15 +168,16 @@
<div class="preview">
<span class="preview-k">Will create</span>
<span class="preview-v">{previewName || '—'}</span>
<span class="preview-sub">(lobby number + port auto-assigned)</span>
<span class="preview-sub">{alreadyDeployed ? "Already deployed; choose another lobby" : "Port auto-assigned"}</span>
</div>
<a href="/server-keys" onclick={close}>Manage saved keys</a>
<div class="actions">
<Button variant="ghost" onclick={close} disabled={deploying}>Cancel</Button>
<Button
type="submit"
variant="primary"
disabled={deploying || !selectedMap}
disabled={deploying || !selectedMap || !keysLoaded || alreadyDeployed}
loading={deploying}
>
Deploy server
@@ -217,7 +251,7 @@
gap: var(--space-4);
}
.fields :global(.field):nth-child(1),
.fields :global(.field):nth-child(4) {
.fields :global(.field):nth-child(5) {
grid-column: 1 / -1;
}
+2 -1
View File
@@ -22,7 +22,7 @@
// `g b` betas, `g s` scripts. Ignored while typing in inputs.
const shortcuts: Record<string, string> = {
f: '/', d: '/deployments', b: '/betas',
s: '/scripts', c: '/configs', p: '/plugins'
k: '/server-keys', s: '/scripts', c: '/configs', p: '/plugins'
};
let armed = false;
let armTimer: ReturnType<typeof setTimeout> | null = null;
@@ -65,6 +65,7 @@
{ href: '/configs', label: 'Configs', match: (p: string) => p.startsWith('/configs') },
{ href: '/plugins', label: 'Plugins', match: (p: string) => p.startsWith('/plugins') },
{ href: '/deployments', label: 'Deployments', match: (p: string) => p.startsWith('/deployments') },
{ href: '/server-keys', label: 'Server keys', match: (p: string) => p.startsWith('/server-keys') },
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') }
];
</script>
+22
View File
@@ -0,0 +1,22 @@
import { json } from '@sveltejs/kit';
import { listServerKeys, saveServerKey, removeServerKey, validServerId } from '$lib/server/fleet/server-keys.js';
export function GET() {
return json({ keys: listServerKeys() }, { headers: { 'Cache-Control': 'no-store' } });
}
export async function PUT({ request }: { request: Request }) {
const body = await request.json().catch(() => null);
try {
saveServerKey(body?.serverId, body?.plutoKey);
return json({ ok: true });
} catch (err) {
return json({ ok: false, error: (err as Error).message }, { status: 400 });
}
}
export async function DELETE({ request }: { request: Request }) {
const body = await request.json().catch(() => null);
if (!validServerId(body?.serverId)) return json({ error: 'Invalid server ID' }, { status: 400 });
return json({ ok: removeServerKey(body.serverId) });
}
+5 -7
View File
@@ -43,25 +43,23 @@ export async function POST({ request }: { request: Request }) {
gameMode?: string;
mapSlug?: string;
plutoKey?: string;
lobby?: number;
};
if (typeof body.gameMode !== 'string' || typeof body.mapSlug !== 'string' || typeof body.plutoKey !== 'string') {
if (!body || typeof body.gameMode !== 'string' || typeof body.mapSlug !== 'string' || (body.plutoKey !== undefined && typeof body.plutoKey !== 'string') || (body.lobby !== undefined && typeof body.lobby !== 'number')) {
return json(
{ ok: false, error: 'gameMode, mapSlug, and plutoKey are required strings', kind: 'bad-request' },
{ ok: false, error: 'gameMode and mapSlug are required strings; plutoKey and lobby are optional', kind: 'bad-request' },
{ status: 400 }
);
}
if (body.plutoKey.trim().length === 0) {
return json({ ok: false, error: 'plutoKey cannot be empty', kind: 'bad-request' }, { status: 400 });
}
const result = await createServer({
gameMode: body.gameMode as GameMode,
mapSlug: body.mapSlug,
plutoKey: body.plutoKey,
lobby: body.lobby,
createdBy: 'ui'
});
if (!result.ok) {
const status = result.kind === 'unconfigured' ? 503
const status = result.kind === 'bad-request' ? 400 : result.kind === 'unconfigured' ? 503
: result.kind === 'unknown-map' || result.kind === 'no-slot' || result.kind === 'no-port' ? 409
: 500;
return json({ ok: false, error: result.message, kind: result.kind }, { status });
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
import { onMount } from 'svelte';
import Button from '$lib/ui/components/primitives/Button.svelte';
import Field from '$lib/ui/components/primitives/Field.svelte';
import Input from '$lib/ui/components/primitives/Input.svelte';
interface SavedKey { server_id: string; deployed: number; updated_at: string }
let keys = $state<SavedKey[]>([]);
let serverId = $state('');
let plutoKey = $state('');
let editing = $state(false);
let busy = $state(false);
let loading = $state(true);
let error = $state('');
let notice = $state('');
async function load() {
const res = await fetch('/api/server-keys');
if (!res.ok) throw new Error('Could not load server keys');
keys = (await res.json()).keys;
}
onMount(() => { load().catch(e => error = e.message).finally(() => loading = false); });
function reset() { serverId = ''; plutoKey = ''; editing = false; }
async function save(e: SubmitEvent) {
e.preventDefault();
busy = true; error = ''; notice = '';
try {
const res = await fetch('/api/server-keys', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverId: serverId.trim(), plutoKey })
});
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? 'Could not save key');
reset(); notice = 'Server key saved.'; await load();
} catch (e) { error = (e as Error).message; }
finally { busy = false; }
}
async function remove(id: string) {
if (!confirm(`Remove the saved key for ${id}? Running servers are unaffected.`)) return;
busy = true; error = ''; notice = '';
try {
const res = await fetch('/api/server-keys', {
method: 'DELETE', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverId: id })
});
if (!res.ok) throw new Error('Could not remove key');
if (serverId === id) reset();
notice = 'Saved key removed.'; await load();
} catch (e) { error = (e as Error).message; }
finally { busy = false; }
}
</script>
<svelte:head><title>Server keys · Electric Cherry</title></svelte:head>
<h1>Server keys</h1>
<p>Save a Plutonium key for each server ID, even when the server is not deployed. Destroying a server keeps its saved key.</p>
<form onsubmit={save}>
<h2>{editing ? 'Update key' : 'Add key'}</h2>
<Field label="Server ID" hint="Use a map slug and lobby number from 1 to 20, e.g. t5_zombie_theater_2.">
<Input bind:value={serverId} placeholder="t5_zombie_theater_2" required readonly={editing} disabled={busy} mono />
</Field>
<Field label="Plutonium server key" hint="Changes apply the next time this server ID is deployed.">
<Input type="password" bind:value={plutoKey} placeholder="Paste key" autocomplete="off" required disabled={busy} />
</Field>
<div class="actions">
<Button type="submit" variant="primary" disabled={busy || loading} loading={busy}>{editing ? 'Update key' : 'Save key'}</Button>
{#if editing}<Button onclick={reset} disabled={busy}>Cancel</Button>{/if}
</div>
</form>
{#if error}<p role="alert" class="error">{error}</p>{/if}
{#if notice}<p role="status">{notice}</p>{/if}
<h2>Saved keys</h2>
{#if loading}<p>Loading keys…</p>
{:else if keys.length === 0}<p>No saved keys yet. Add one above or save one when deploying a server.</p>
{:else}
<div class="table-wrap"><table>
<thead><tr><th>Server ID</th><th>Status</th><th>Key</th><th>Actions</th></tr></thead>
<tbody>{#each keys as key (key.server_id)}
<tr>
<td><code>{key.server_id}</code></td>
<td>{key.deployed ? 'Deployed' : 'Not deployed'}</td>
<td aria-label="Key saved">••••••••</td>
<td><div class="actions">
<Button disabled={busy} onclick={() => { serverId = key.server_id; plutoKey = ''; editing = true; error = ''; notice = ''; }}>Update</Button>
<Button disabled={busy} onclick={() => remove(key.server_id)}>Remove</Button>
</div></td>
</tr>
{/each}</tbody>
</table></div>
{/if}
<style>
h1 { margin-top: 0; }
h2 { font-size: var(--text-md); }
p { color: var(--text-secondary); }
form { display: grid; gap: var(--space-4); max-width: 620px; padding: var(--space-6); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); margin: var(--space-6) 0; }
form h2 { margin: 0; }
.actions { display: flex; gap: var(--space-2); }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; text-align: left; }
th, td { padding: var(--space-4); border-bottom: 1px solid var(--border-subtle); }
th { color: var(--text-tertiary); font-size: var(--text-sm); }
code { font-family: var(--font-mono); }
.error { color: var(--state-danger); }
</style>
+47
View File
@@ -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);
});
});