feat: web setup wizard + admin config/state dir split (#226)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user