feat: web setup wizard + admin config/state dir split (#226)

This commit is contained in:
Linus Rath
2026-05-09 17:37:41 +02:00
parent c44a9ce6e0
commit 51745ea03d
36 changed files with 2612 additions and 164 deletions
+33
View File
@@ -0,0 +1,33 @@
import { cookies } from 'next/headers';
import { verifySetupToken } from './token';
export const SETUP_COOKIE = 'bulwark_setup_token';
const COOKIE_MAX_AGE = 60 * 60; // 1 hour, matches token TTL
/**
* The wizard "session" is just the setup token itself, set as an HttpOnly
* cookie after the operator pastes it into step 1. Subsequent step calls
* re-verify the cookie value against the .setup-token file. When the wizard
* finishes, the token file is deleted and any cookies become useless.
*
* No JWT, no separate signing key, no rotating session id. The lifecycle of
* the wizard maps 1:1 to the lifecycle of the token file.
*/
export async function authenticateWizardRequest(): Promise<boolean> {
const jar = await cookies();
const token = jar.get(SETUP_COOKIE)?.value;
if (!token) return false;
return verifySetupToken(token);
}
export function buildSessionCookieAttributes() {
return {
name: SETUP_COOKIE,
httpOnly: true,
sameSite: 'lax' as const,
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: COOKIE_MAX_AGE,
};
}
+53
View File
@@ -0,0 +1,53 @@
import { existsSync } from 'node:fs';
import { configManager } from '@/lib/admin/config-manager';
import { getConfigPath, isConfigReadOnly } from '@/lib/admin/paths';
/**
* The three lifecycle states for the running container.
*
* bootstrap - no config persisted yet and no JMAP_SERVER_URL env. The
* setup wizard is served at /setup; everything else 302s
* there.
* configured - setup wizard finished (admin override config.json carries
* setupComplete=true). Normal app; /setup returns 404.
* env-managed - JMAP_SERVER_URL is set in the environment, so the
* operator is configuring via .env (legacy / CI path). The
* wizard stays disabled.
*/
export type SetupState = 'bootstrap' | 'configured' | 'env-managed';
/**
* Cheap to call on every request. configManager keeps `setupComplete` in
* memory after the initial load, so this is just env reads + an in-memory
* boolean check.
*/
export function detectSetupState(): SetupState {
if (configManager.isSetupComplete()) return 'configured';
if (process.env.JMAP_SERVER_URL && process.env.JMAP_SERVER_URL.trim() !== '') {
return 'env-managed';
}
// Read-only config dir + no setupComplete flag means the volume was
// mounted :ro before the wizard ran. Fall through to bootstrap so the
// failure (write attempt during wizard) surfaces with a clear error
// rather than silently 404'ing /setup.
if (isConfigReadOnly()) return 'bootstrap';
return 'bootstrap';
}
/**
* Whether the wizard's UI and APIs should be reachable.
*/
export function isSetupActive(): boolean {
return detectSetupState() === 'bootstrap';
}
/**
* The persisted `.config-locked` marker the wizard drops when the operator
* checks "lock configuration after setup" on the review screen. Purely
* advisory - the actual locking is the operator's `:ro` mount or the
* ADMIN_CONFIG_READONLY env var. This file is what the admin UI uses to
* remind the operator that they intended to lock.
*/
export function lockMarkerExists(): boolean {
return existsSync(getConfigPath('.config-locked'));
}
+111
View File
@@ -0,0 +1,111 @@
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { readFile, writeFile, unlink, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { logger } from '@/lib/logger';
import { ensureStateDir, getStatePath } from '@/lib/admin/paths';
const TOKEN_FILE = '.setup-token';
const TOKEN_BYTES = 32;
const DEFAULT_TTL_SECONDS = 60 * 60; // 1 hour
interface TokenPayload {
token: string;
issuedAt: number;
ttlSeconds: number;
}
/**
* Read the current token if one exists and hasn't expired. Stale tokens
* are deleted lazily - first stale read removes the file.
*/
async function readToken(): Promise<TokenPayload | null> {
const path = getStatePath(TOKEN_FILE);
if (!existsSync(path)) return null;
try {
const raw = await readFile(path, 'utf-8');
const payload = JSON.parse(raw) as TokenPayload;
if (Date.now() / 1000 - payload.issuedAt > payload.ttlSeconds) {
try { await unlink(path); } catch { /* ok */ }
return null;
}
return payload;
} catch (error) {
logger.warn('Failed to read setup token', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return null;
}
}
/**
* Generate (or refresh) the setup token. Called at startup when the app
* detects bootstrap state. Idempotent: returns the existing token if it's
* still valid, otherwise issues a fresh one.
*
* The token lands in a file in ADMIN_STATE_DIR (always writable, never
* read-only) and is also printed to the container logs so the operator
* can copy it without execing into the container.
*/
export async function ensureSetupToken(ttlSeconds: number = DEFAULT_TTL_SECONDS): Promise<string> {
const existing = await readToken();
if (existing) return existing.token;
await ensureStateDir();
const token = randomBytes(TOKEN_BYTES).toString('hex');
const payload: TokenPayload = {
token,
issuedAt: Math.floor(Date.now() / 1000),
ttlSeconds,
};
const path = getStatePath(TOKEN_FILE);
await writeFile(path, JSON.stringify(payload, null, 2), 'utf-8');
return token;
}
/**
* Verify a token submitted by the wizard. Constant-time comparison; never
* leak the stored token via timing.
*/
export async function verifySetupToken(submitted: string): Promise<boolean> {
if (!submitted || typeof submitted !== 'string') return false;
const stored = await readToken();
if (!stored) return false;
const a = Buffer.from(submitted);
const b = Buffer.from(stored.token);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
/**
* Delete the token file. Called by the wizard's finish endpoint after
* setupComplete=true is persisted.
*/
export async function clearSetupToken(): Promise<void> {
const path = getStatePath(TOKEN_FILE);
try {
await unlink(path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
logger.warn('Failed to clear setup token', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
/**
* For diagnostics / startup logging.
*/
export async function getTokenInfo(): Promise<{ exists: boolean; expiresInSeconds: number | null }> {
const path = getStatePath(TOKEN_FILE);
if (!existsSync(path)) return { exists: false, expiresInSeconds: null };
try {
await stat(path);
const payload = await readToken();
if (!payload) return { exists: false, expiresInSeconds: null };
const elapsed = Date.now() / 1000 - payload.issuedAt;
return { exists: true, expiresInSeconds: Math.max(0, Math.floor(payload.ttlSeconds - elapsed)) };
} catch {
return { exists: false, expiresInSeconds: null };
}
}