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
+21 -4
View File
@@ -78,10 +78,27 @@ JMAP_SERVER_URL=https://your-jmap-server.com
# Admin Dashboard Data
# =============================================================================
# Directory for admin dashboard state: config overrides, admin password hash,
# installed plugins/themes, and audit logs (default: ./data/admin).
# For Docker, the default resolves to /app/data/admin - mount a persistent
# volume there (see docker-compose.yml).
# Admin data is split across two directories so the config volume can be
# mounted read-only after the setup wizard completes (see issue #226).
#
# Config dir - operator-authored state. Holds config.json, policy.json,
# admin.json (passwordHash only), plugin-config/, plugins/, themes/, and
# branding uploads. Safe to mount read-only after setup.
# Default: ./data/admin (or ADMIN_DATA_DIR if that legacy variable is set)
# ADMIN_CONFIG_DIR=./data/admin
#
# State dir - runtime mutations. Holds admin-state.json (login timestamps),
# audit.log, and the bootstrap setup token. Always read-write.
# Default: ./data/admin-state (or ADMIN_DATA_DIR/state when ADMIN_DATA_DIR
# is set, for back-compat with single-volume installs)
# ADMIN_STATE_DIR=./data/admin-state
#
# Set to "true" to enforce read-only mode at the application layer (cleaner
# error than a mid-request EROFS). Pair with `:ro` on the config-volume mount.
# ADMIN_CONFIG_READONLY=true
#
# Legacy: a single dir containing both config and state. Honoured if neither
# of the split variables is set. New installs should use the split vars.
# ADMIN_DATA_DIR=./data/admin
# =============================================================================
+1 -1
View File
@@ -34,7 +34,7 @@ RUN apk upgrade --no-cache && \
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN mkdir -p /app/data/settings /app/data/admin /app/data/telemetry && chown -R nextjs:nodejs /app/data
RUN mkdir -p /app/data/settings /app/data/admin /app/data/admin-state /app/data/telemetry && chown -R nextjs:nodejs /app/data
USER nextjs
EXPOSE 3000
ENV PORT=3000
+3 -3
View File
@@ -47,13 +47,13 @@ export default function AdminLoginPage() {
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8">
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-8 h-8 object-contain" />
<img src={logoUrl} alt="" className="h-12 object-contain mb-4" />
) : (
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
<Shield className="w-6 h-6 text-primary" />
)}
</div>
)}
<h1 className="text-xl font-semibold text-foreground">Admin Dashboard</h1>
<p className="text-sm text-muted-foreground mt-1">Enter your admin password to continue</p>
</div>
+2 -2
View File
@@ -7,14 +7,14 @@ import { getRequiredConfig } from '@/lib/oauth/token-exchange';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
import { hasSessionSecret } from '@/lib/auth/session-secret';
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
export async function POST(request: NextRequest) {
try {
if (!process.env.SESSION_SECRET && !readFileEnv(process.env.SESSION_SECRET_FILE)) {
if (!hasSessionSecret()) {
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
}
+3 -3
View File
@@ -1,8 +1,8 @@
import { NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env';
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
import { hasSessionSecret } from '@/lib/auth/session-secret';
/**
* Runtime configuration endpoint
@@ -35,8 +35,8 @@ export async function GET() {
oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
rememberMeEnabled: hasSessionSecret(),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
stalwartFeaturesEnabled,
devMode: configManager.get<boolean>('devMode', false),
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
+5 -2
View File
@@ -6,7 +6,7 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
function classifyError(error: unknown): { message: string; status: number } {
@@ -50,7 +50,10 @@ function classifyError(error: unknown): { message: string; status: number } {
}
function isEnabled(): boolean {
return process.env.SETTINGS_SYNC_ENABLED === 'true' && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE));
const flagOn =
process.env.SETTINGS_SYNC_ENABLED === 'true' ||
configManager.get<boolean>('settingsSyncEnabled', false);
return flagOn && hasSessionSecret();
}
/** Strip trailing slashes so differently-formatted URLs still match. */
+107
View File
@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from 'next/server';
import { writeFile } from 'node:fs/promises';
import { detectSetupState } from '@/lib/setup/state';
import { authenticateWizardRequest, SETUP_COOKIE } from '@/lib/setup/session';
import { configManager } from '@/lib/admin/config-manager';
import { setInitialAdminPassword } from '@/lib/admin/password';
import { clearSetupToken } from '@/lib/setup/token';
import { ensureConfigDir, getConfigPath } from '@/lib/admin/paths';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
export const dynamic = 'force-dynamic';
/**
* POST /api/setup/finish
*
* Final wizard step. Validates that required config is in place, hashes the
* admin password, marks setup complete, deletes the setup token (which
* invalidates the wizard cookie), and optionally drops a `.config-locked`
* marker so the operator remembers they intended to mount :ro.
*
* Body: { adminPassword: string, lockConfig?: boolean }
*/
export async function POST(request: NextRequest) {
if (detectSetupState() !== 'bootstrap') {
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
}
if (!(await authenticateWizardRequest())) {
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
}
let body: { adminPassword?: unknown; lockConfig?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const adminPassword =
typeof body?.adminPassword === 'string' ? body.adminPassword : '';
if (adminPassword.length < 8) {
return NextResponse.json(
{ error: 'Admin password must be at least 8 characters' },
{ status: 400 },
);
}
const lockConfig = body?.lockConfig === true;
// Validate required config is present.
await configManager.ensureLoaded();
const jmapUrl = configManager.get<string>('jmapServerUrl', '');
if (!jmapUrl || typeof jmapUrl !== 'string') {
return NextResponse.json(
{ error: 'JMAP server URL is required (run the Server step first)' },
{ status: 400 },
);
}
try {
// 1. Provision the admin account. Aborts cleanly if one already exists
// (defence in depth - should be impossible in bootstrap state).
const created = await setInitialAdminPassword(adminPassword);
if (!created) {
return NextResponse.json(
{ error: 'Admin account already exists; cannot finish setup again' },
{ status: 409 },
);
}
// 2. Persist setupComplete flag. After this, detectSetupState() flips
// to 'configured' and middleware starts 404'ing /setup paths.
await configManager.markSetupComplete();
// 3. Optional advisory lock marker.
if (lockConfig) {
await ensureConfigDir();
await writeFile(
getConfigPath('.config-locked'),
new Date().toISOString(),
'utf-8',
);
}
// 4. Destroy the setup token. Any other browser holding the cookie is
// now unauthenticated.
await clearSetupToken();
await auditLog(
'setup.finish',
{ lockConfig, jmapServerUrl: jmapUrl },
request.headers.get('x-forwarded-for') ?? 'unknown',
);
const response = NextResponse.json({ ok: true, lockConfig });
response.cookies.delete(SETUP_COOKIE);
return response;
} catch (error) {
logger.error('Wizard finish failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json(
{ error: 'Failed to finish setup', detail: error instanceof Error ? error.message : 'Unknown' },
{ status: 500 },
);
}
}
+52
View File
@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server';
import { detectSetupState } from '@/lib/setup/state';
import { authenticateWizardRequest } from '@/lib/setup/session';
import { configManager } from '@/lib/admin/config-manager';
import { isConfigReadOnly } from '@/lib/admin/paths';
import { SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
export const dynamic = 'force-dynamic';
/**
* GET /api/setup/status - public endpoint that returns the wizard state
* and (if authenticated) the partial config saved by previous steps. The
* wizard polls this on load so a refresh resumes with prior values.
*
* Sensitive values (OAuth client secret, session secret) are NEVER sent
* back to the client - only a `<key>HasValue` boolean. Re-entering them
* after refresh is the price of not exposing them.
*/
export async function GET() {
await configManager.ensureLoaded();
const state = detectSetupState();
const authenticated = state === 'bootstrap' ? await authenticateWizardRequest() : false;
let partialConfig: Record<string, unknown> | null = null;
if (state === 'bootstrap' && authenticated) {
// Only echo back values the operator has actually saved during the
// wizard (admin overrides). System defaults must not flow back here,
// because the wizard has its own opinionated defaults (e.g. settings
// sync on by default) that we'd otherwise stomp.
const sources = configManager.getAllWithSources();
const safe: Record<string, unknown> = {};
for (const [key, info] of Object.entries(sources)) {
if (info.source !== 'admin') continue;
if (SENSITIVE_CONFIG_KEYS.has(key)) {
safe[`${key}HasValue`] = typeof info.value === 'string' && info.value.length > 0;
} else {
safe[key] = info.value;
}
}
partialConfig = safe;
}
return NextResponse.json(
{
state,
authenticated,
readOnly: isConfigReadOnly(),
partialConfig,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
}
+118
View File
@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from 'next/server';
import { detectSetupState } from '@/lib/setup/state';
import { authenticateWizardRequest } from '@/lib/setup/session';
import { configManager } from '@/lib/admin/config-manager';
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
import { parseJmapServers } from '@/lib/admin/jmap-servers';
import { logger } from '@/lib/logger';
export const dynamic = 'force-dynamic';
/**
* Mapping of wizard-friendly step keys to the config keys they update. Each
* step's PATCH validates against this allowlist so a compromised wizard
* client can't slip in arbitrary config keys.
*/
const STEP_KEYS: Record<string, string[]> = {
server: [
'appName',
'jmapServerUrl',
'stalwartFeaturesEnabled',
'jmapServers',
'jmapServerAutoPickByDomain',
],
auth: [
'oauthEnabled',
'oauthOnly',
'oauthClientId',
'oauthClientSecret',
'oauthIssuerUrl',
],
security: ['sessionSecret', 'settingsSyncEnabled'],
logging: ['logFormat', 'logLevel'],
branding: [
'faviconUrl',
'appLogoLightUrl',
'appLogoDarkUrl',
'loginLogoLightUrl',
'loginLogoDarkUrl',
'loginCompanyName',
'loginImprintUrl',
'loginPrivacyPolicyUrl',
'loginWebsiteUrl',
],
};
/**
* POST /api/setup/step
* Body: { step: 'server' | 'auth' | ..., values: Record<string, unknown> }
*
* Persists partial config under the admin override (config.json). Each
* step's allowed keys are restricted by STEP_KEYS so the client can only
* touch what the corresponding screen owns.
*/
export async function POST(request: NextRequest) {
if (detectSetupState() !== 'bootstrap') {
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
}
if (!(await authenticateWizardRequest())) {
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
}
let body: { step?: unknown; values?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const step = typeof body?.step === 'string' ? body.step : '';
const values = body?.values;
const allowedKeys = STEP_KEYS[step];
if (!allowedKeys) {
return NextResponse.json({ error: `Unknown step: ${step}` }, { status: 400 });
}
if (!values || typeof values !== 'object' || Array.isArray(values)) {
return NextResponse.json({ error: 'values must be an object' }, { status: 400 });
}
const updates: Record<string, unknown> = {};
for (const [key, value] of Object.entries(values as Record<string, unknown>)) {
if (!allowedKeys.includes(key)) {
return NextResponse.json({ error: `Key not allowed in step ${step}: ${key}` }, { status: 400 });
}
if (!(key in CONFIG_ENV_MAP)) {
return NextResponse.json({ error: `Unknown config key: ${key}` }, { status: 400 });
}
if (key === 'jmapServers') {
// Sanitize: drop entries with bad ids, dup ids, or non-HTTP URLs
// before they're persisted. Mirrors the admin config PATCH route.
if (value != null && !Array.isArray(value)) {
return NextResponse.json({ error: 'jmapServers must be an array' }, { status: 400 });
}
const sanitized = parseJmapServers(value);
const incomingCount = Array.isArray(value) ? value.length : 0;
if (sanitized.length !== incomingCount) {
return NextResponse.json(
{ error: `One or more jmapServers entries were invalid (kept ${sanitized.length}/${incomingCount})` },
{ status: 400 },
);
}
updates[key] = sanitized;
continue;
}
updates[key] = value;
}
try {
await configManager.ensureLoaded();
await configManager.setAdminConfig(updates);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Wizard step save failed', {
step,
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'Failed to save step' }, { status: 500 });
}
}
+104
View File
@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from 'next/server';
import { detectSetupState } from '@/lib/setup/state';
import { authenticateWizardRequest } from '@/lib/setup/session';
export const dynamic = 'force-dynamic';
const JMAP_ENDPOINTS = ['/.well-known/jmap', '/jmap/session', '/jmap'];
const FETCH_TIMEOUT_MS = 5000;
/**
* POST /api/setup/test-jmap - server-side probe of a JMAP server. Mirrors
* the check_jmap_server() helper in setup.sh: we hit a few common session
* endpoints and look for capability strings to confirm the URL is actually
* a JMAP server (vs. a generic HTTP 200 page).
*
* Body: { url: string }
*/
export async function POST(request: NextRequest) {
if (detectSetupState() !== 'bootstrap') {
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
}
if (!(await authenticateWizardRequest())) {
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
}
let body: { url?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const raw = typeof body?.url === 'string' ? body.url.trim() : '';
if (!raw) {
return NextResponse.json({ error: 'url required' }, { status: 400 });
}
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return NextResponse.json({ status: 'invalid_url', message: 'URL is not well-formed' });
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return NextResponse.json({ status: 'invalid_url', message: 'URL must use http or https' });
}
const base = raw.replace(/\/+$/, '');
for (const endpoint of JMAP_ENDPOINTS) {
const target = base + endpoint;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const res = await fetch(target, {
method: 'GET',
redirect: 'follow',
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) continue;
const text = await res.text();
if (looksLikeJmapSession(text)) {
return NextResponse.json({
status: 'jmap_detected',
endpoint,
httpStatus: res.status,
});
}
} catch {
// Try the next endpoint; we'll fall through to a final reachability
// check below if none match.
}
}
// No JMAP session found. Was the server even reachable?
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const res = await fetch(base, {
method: 'HEAD',
redirect: 'follow',
signal: controller.signal,
});
clearTimeout(timer);
return NextResponse.json({
status: 'reachable_no_jmap',
httpStatus: res.status,
message:
'Server responded but no JMAP session was found at standard paths. ' +
'This is OK if a reverse proxy routes JMAP separately.',
});
} catch (error) {
return NextResponse.json({
status: 'unreachable',
message: error instanceof Error ? error.message : 'Connection failed',
});
}
}
function looksLikeJmapSession(body: string): boolean {
return /"capabilities"|"apiUrl"|"downloadUrl"|"urn:ietf:params:jmap/i.test(body);
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { detectSetupState } from '@/lib/setup/state';
import { verifySetupToken } from '@/lib/setup/token';
import { buildSessionCookieAttributes } from '@/lib/setup/session';
export const dynamic = 'force-dynamic';
/**
* POST /api/setup/token - exchange the bootstrap token (printed to logs at
* startup) for a wizard session cookie. After this, subsequent step calls
* authenticate via the cookie instead of pasting the token every time.
*
* Body: { token: string }
*/
export async function POST(request: NextRequest) {
if (detectSetupState() !== 'bootstrap') {
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
}
let body: { token?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const submitted = typeof body?.token === 'string' ? body.token.trim() : '';
if (!submitted) {
return NextResponse.json({ error: 'Token required' }, { status: 400 });
}
const ok = await verifySetupToken(submitted);
if (!ok) {
// Don't differentiate between "wrong token" and "no token issued" - the
// operator either has it from the logs or they don't.
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 });
}
const response = NextResponse.json({ ok: true });
const attrs = buildSessionCookieAttributes();
response.cookies.set(attrs.name, submitted, {
httpOnly: attrs.httpOnly,
sameSite: attrs.sameSite,
secure: attrs.secure,
path: attrs.path,
maxAge: attrs.maxAge,
});
return response;
}
+5
View File
@@ -0,0 +1,5 @@
import type { ReactNode } from 'react';
export default function SetupLayout({ children }: { children: ReactNode }) {
return <div className="min-h-screen bg-background text-foreground">{children}</div>;
}
+1288
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -281,7 +281,7 @@ export function EmailComposer({
const currentIdentity = selectedIdentityId
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
: primaryIdentity;
// Alias identities often lack a configured signature fall back to the primary
// Alias identities often lack a configured signature - fall back to the primary
// identity's signature so replies (which auto-select a matching alias) still
// populate the user's signature.
const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature)
+3 -3
View File
@@ -2296,7 +2296,7 @@ export function EmailViewer({
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
// Per RFC 8621 § 4.1.4, when a message has only one alternative the server
// exposes the same part in both htmlBody and textBody. The shared part may
// actually be text/plain (plain-text-only mail) rendering that as HTML
// actually be text/plain (plain-text-only mail) - rendering that as HTML
// collapses newlines and skips linkification, so route by the part's type.
const htmlPart = email.htmlBody[0];
if (htmlPart.type && htmlPart.type.toLowerCase() !== 'text/html') {
@@ -2691,7 +2691,7 @@ export function EmailViewer({
// double re-inverting images nested inside those containers.
// Nested bgcolor containers must NOT add another invert layer: each filter
// toggles the inversion, so an odd number of stacked filters (e.g. body +
// outer bgcolor table + inner bgcolor table) produces an inverted result
// outer bgcolor table + inner bgcolor table) produces an inverted result -
// i.e. light-on-light. The second rule disables filter on bgcolor-like
// elements that are descendants of another bgcolor-like element.
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
@@ -2888,7 +2888,7 @@ export function EmailViewer({
// Re-invert emoji glyphs so they keep their original colors. The
// body's invert filter flips colored emoji (yellow smiley → blue,
// red heart → cyan, etc.). Wrap each emoji run in a span that
// re-inverts. Only act when the ancestor invert depth is odd
// re-inverts. Only act when the ancestor invert depth is odd -
// emojis inside a double-inverted bgcolor container already render
// at their original colors.
let emojiRe: RegExp;
@@ -331,7 +331,7 @@ function EmailCard({
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
// Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting).
// Server-generated HTML from text/plain emails often lacks <br> tags, collapsing newlines.
// Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody
// Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody -
// in that case there is no real plain-text alternative, so always render the HTML.
const textPartId = email.textBody?.[0]?.partId;
const htmlPartId = email.htmlBody[0].partId;
+1 -1
View File
@@ -114,7 +114,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
setDefaultAccount(accountId);
};
// Show the account's own identity, not the preferred sending identity
// Show the account's own identity, not the preferred sending identity -
// primaryIdentity can be an alias (e.g. info@korazo.net) that differs from
// the actually logged-in account (info@linusrath.de).
const displayName = activeAccount?.displayName || activeAccount?.label || "";
+7 -1
View File
@@ -11,8 +11,13 @@ services:
volumes:
# Encrypted user settings (SETTINGS_DATA_DIR).
- bulwark-settings:/app/data/settings
# Admin dashboard state: config, password hash, plugins, audit logs (ADMIN_DATA_DIR).
# Admin configuration: config.json, policy.json, admin.json (passwordHash),
# plugins, themes, branding uploads (ADMIN_CONFIG_DIR). Can be mounted
# read-only after running the setup wizard - append `:ro` to lock it.
- bulwark-admin:/app/data/admin
# Admin runtime state: admin-state.json (login timestamps), audit.log,
# setup token (ADMIN_STATE_DIR). Always read-write.
- bulwark-admin-state:/app/data/admin-state
# Anonymous telemetry: instance id, consent state, login HMACs (TELEMETRY_DATA_DIR).
# Persisting this preserves the admin's consent choice and stable instance id across upgrades.
- bulwark-telemetry:/app/data/telemetry
@@ -35,4 +40,5 @@ services:
volumes:
bulwark-settings:
bulwark-admin:
bulwark-admin-state:
bulwark-telemetry:
+31 -3
View File
@@ -1,6 +1,9 @@
import { readFileSync } from "fs";
import { configManager } from "./lib/admin/config-manager";
import { initAdminPassword } from "./lib/admin/password";
import { migrateLegacyAdminLayout } from "./lib/admin/migrate";
import { detectSetupState } from "./lib/setup/state";
import { ensureSetupToken } from "./lib/setup/token";
const pkg = JSON.parse(
readFileSync(`${process.cwd()}/package.json`, "utf-8")
@@ -8,11 +11,36 @@ const pkg = JSON.parse(
const current: string = pkg.version ?? "0.0.0";
console.info(`Bulwark Webmail v${current}`);
// Initialize admin config and password bootstrap
configManager.load()
// Initialize admin config and password bootstrap. Migration runs first so
// existing v1 layouts are split before anything reads admin.json.
migrateLegacyAdminLayout()
.then(() => configManager.load())
.then(() => initAdminPassword())
.then(() => {
.then(async () => {
console.info("Admin dashboard initialized");
// If we're in bootstrap state (no JMAP_SERVER_URL env and no
// setupComplete in config.json), generate/refresh the setup token and
// print it to the logs so the operator can complete the web wizard
// without execing into the container.
if (detectSetupState() === "bootstrap") {
try {
const token = await ensureSetupToken();
const port = process.env.PORT || "3000";
console.info("");
console.info("==============================================================");
console.info(" SETUP REQUIRED");
console.info(` Token: ${token}`);
console.info(` Open: http://<host>:${port}/setup?token=${token}`);
console.info(" Token expires in 1 hour. Restart the container to reissue.");
console.info("==============================================================");
console.info("");
} catch (err) {
console.warn(
"Failed to issue setup token:",
err instanceof Error ? err.message : err,
);
}
}
})
.then(async () => {
// Anonymous telemetry - on by default. Admins can disable via the
+2 -2
View File
@@ -65,7 +65,7 @@ export function getAccountScopedKey(baseKey: string, accountId: string): string
/**
* Hard upper bound on cookie slots. Each slot can hold up to ~3 cookies
* (session, refresh token, server id, auth context), so 50 slots ≈ 125
* cookies on average within Firefox's per-domain limit of 150.
* cookies on average - within Firefox's per-domain limit of 150.
*/
export const MAX_ACCOUNT_SLOTS = 50;
@@ -83,7 +83,7 @@ export const MAX_ACCOUNTS_HTTP1 = 5;
* We walk recent resource-timing entries and treat a single h2/h3 sighting
* as a positive signal. Cross-origin entries may report an empty
* `nextHopProtocol` without `Timing-Allow-Origin`, in which case we
* under-detect and fall back to the conservative cap that's safe.
* under-detect and fall back to the conservative cap - that's safe.
*/
export function isHttp2Available(): boolean {
if (typeof performance === 'undefined') return false;
+7 -14
View File
@@ -1,28 +1,23 @@
import { appendFile, stat, rename, mkdir } from 'node:fs/promises';
import { appendFile, stat, rename, readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
import { ensureStateDir, getStatePath } from './paths';
import type { AuditEntry } from './types';
const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB
const MAX_ROTATIONS = 3;
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
}
const AUDIT_LOG_FILE = 'audit.log';
function getAuditLogPath(): string {
return path.join(getAdminDir(), 'audit.log');
return getStatePath(AUDIT_LOG_FILE);
}
/**
* Append an audit entry to the admin audit log.
* Append an audit entry to the admin audit log. Stored under the state dir
* so it remains writable when the config dir is mounted read-only.
*/
export async function auditLog(action: string, detail: Record<string, unknown>, ip: string): Promise<void> {
const dir = getAdminDir();
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
await ensureStateDir();
const entry: AuditEntry = {
ts: new Date().toISOString(),
@@ -64,7 +59,6 @@ async function rotateIfNeeded(logPath: string): Promise<void> {
export async function readAuditLog(page: number = 1, limit: number = 50, actionFilter?: string): Promise<{ entries: AuditEntry[]; total: number }> {
const logPath = getAuditLogPath();
try {
const { readFile } = await import('node:fs/promises');
const content = await readFile(logPath, 'utf-8');
const lines = content.trim().split('\n').filter(Boolean);
@@ -77,7 +71,6 @@ export async function readAuditLog(page: number = 1, limit: number = 50, actionF
}
const total = entries.length;
// Return newest first
entries.reverse();
const start = (page - 1) * limit;
return { entries: entries.slice(start, start + limit), total };
+36 -14
View File
@@ -1,13 +1,8 @@
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { readFile, writeFile, rename } from 'node:fs/promises';
import { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env';
import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
}
import { ensureConfigDir, getConfigPath, assertWritable } from './paths';
function parseEnvValue(value: string, type: string): unknown {
switch (type) {
@@ -127,6 +122,7 @@ class ConfigManager {
* Update admin config overrides. Writes to disk.
*/
async setAdminConfig(updates: Record<string, unknown>): Promise<void> {
assertWritable('update admin config');
Object.assign(this.adminConfig, updates);
await this.writeJsonFile('config.json', this.adminConfig);
}
@@ -135,10 +131,29 @@ class ConfigManager {
* Remove an admin override, reverting to env/default.
*/
async removeAdminOverride(key: string): Promise<void> {
assertWritable('remove admin override');
delete this.adminConfig[key];
await this.writeJsonFile('config.json', this.adminConfig);
}
/**
* Whether the setup wizard has completed. Used by middleware to gate the
* /setup routes and the rest of the app.
*/
isSetupComplete(): boolean {
return this.adminConfig.setupComplete === true;
}
/**
* Mark setup wizard as complete. Called by the wizard's finish endpoint
* after all other config has been written. Refuses in read-only mode.
*/
async markSetupComplete(): Promise<void> {
assertWritable('mark setup complete');
this.adminConfig.setupComplete = true;
await this.writeJsonFile('config.json', this.adminConfig);
}
/**
* Get the current settings policy.
*/
@@ -150,6 +165,7 @@ class ConfigManager {
* Update the settings policy. Writes to disk.
*/
async setPolicy(policy: SettingsPolicy): Promise<void> {
assertWritable('update settings policy');
this.policyCache = {
...DEFAULT_POLICY,
...policy,
@@ -167,7 +183,7 @@ class ConfigManager {
}
private async readJsonFile(filename: string): Promise<Record<string, unknown> | null> {
const filePath = path.join(getAdminDir(), filename);
const filePath = getConfigPath(filename);
try {
const raw = await readFile(filePath, 'utf-8');
return JSON.parse(raw);
@@ -179,15 +195,21 @@ class ConfigManager {
}
private async writeJsonFile(filename: string, data: Record<string, unknown>): Promise<void> {
const dir = getAdminDir();
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
const targetPath = path.join(dir, filename);
await ensureConfigDir();
const targetPath = getConfigPath(filename);
const tmpPath = targetPath + '.tmp';
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
await rename(tmpPath, targetPath);
}
}
export const configManager = new ConfigManager();
// Stash the singleton on globalThis so HMR / multiple module-evaluation
// boundaries (middleware vs route handlers in dev with turbopack) all share
// the same in-memory state. Without this, marking setupComplete=true in a
// route handler is invisible to the next middleware run, and the wizard
// redirect after finish never fires.
const SINGLETON_KEY = Symbol.for('bulwark.admin.configManager');
type GlobalWithConfig = typeof globalThis & { [SINGLETON_KEY]?: ConfigManager };
const g = globalThis as GlobalWithConfig;
export const configManager: ConfigManager =
g[SINGLETON_KEY] ?? (g[SINGLETON_KEY] = new ConfigManager());
+196
View File
@@ -0,0 +1,196 @@
import { readFile, writeFile, rename, stat, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { logger } from '@/lib/logger';
import {
ensureConfigDir,
ensureStateDir,
getConfigPath,
getStatePath,
isConfigReadOnly,
} from './paths';
import type { AdminConfigData, AdminStateData } from './types';
const MIGRATION_MARKER = '.migrated-v2';
interface LegacyAdminData {
passwordHash: string;
createdAt?: string;
lastLogin?: string | null;
passwordChangedAt?: string;
}
/**
* One-shot migration from the v1 layout (everything mixed in `data/admin/`)
* to the v2 layout (config + state split, see lib/admin/paths.ts).
*
* Idempotent: writes a `.migrated-v2` marker into the config dir on success.
*
* Migrations performed:
* 1. admin.json with timestamps → admin.json (passwordHash only) +
* admin-state.json (createdAt, lastLogin, passwordChangedAt)
* 2. audit.log moved from config dir to state dir (by rename if same FS,
* else copy + delete).
*
* Skipped silently when the config dir is read-only - operators who already
* locked their config volume must do the migration manually before mounting
* :ro.
*/
export async function migrateLegacyAdminLayout(): Promise<void> {
if (isConfigReadOnly()) return;
const markerPath = getConfigPath(MIGRATION_MARKER);
if (existsSync(markerPath)) return;
let didWork = false;
try {
didWork = (await migrateAdminJson()) || didWork;
didWork = (await migrateAuditLog()) || didWork;
await ensureConfigDir();
await writeFile(markerPath, new Date().toISOString(), 'utf-8');
if (didWork) {
logger.info('Admin layout migrated to v2 (config/state split)');
}
} catch (error) {
logger.warn('Admin layout migration failed; will retry on next boot', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
/**
* If the existing admin.json carries timestamp fields (legacy mixed layout),
* split them into admin-state.json and rewrite admin.json without them.
* Returns true if a migration was performed.
*/
async function migrateAdminJson(): Promise<boolean> {
const adminJsonPath = getConfigPath('admin.json');
if (!existsSync(adminJsonPath)) return false;
let raw: string;
try {
raw = await readFile(adminJsonPath, 'utf-8');
} catch {
return false;
}
let data: LegacyAdminData;
try {
data = JSON.parse(raw) as LegacyAdminData;
} catch {
logger.warn('admin.json is not valid JSON; skipping migration');
return false;
}
const hasLegacyFields =
'createdAt' in data || 'lastLogin' in data || 'passwordChangedAt' in data;
if (!hasLegacyFields) return false; // already in v2 shape
if (!data.passwordHash || typeof data.passwordHash !== 'string') {
logger.warn('admin.json missing passwordHash; skipping migration');
return false;
}
const now = new Date().toISOString();
const stateData: AdminStateData = {
createdAt: data.createdAt ?? now,
lastLogin: data.lastLogin ?? null,
passwordChangedAt: data.passwordChangedAt ?? now,
};
const configData: AdminConfigData = { passwordHash: data.passwordHash };
await ensureStateDir();
const statePath = getStatePath('admin-state.json');
// If admin-state.json already exists, prefer its values: a previous
// migration may have succeeded and recorded fresh login timestamps that
// we'd otherwise stomp. The legacy admin.json data is older by definition.
if (!existsSync(statePath)) {
const stateTmp = statePath + '.tmp';
await writeFile(stateTmp, JSON.stringify(stateData, null, 2), 'utf-8');
await rename(stateTmp, statePath);
}
const configTmp = adminJsonPath + '.tmp';
await writeFile(configTmp, JSON.stringify(configData, null, 2), 'utf-8');
await rename(configTmp, adminJsonPath);
logger.info('Migrated admin.json: split timestamps into admin-state.json');
return true;
}
/**
* Move audit.log from the config dir to the state dir if present. Returns
* true if a migration was performed. Also moves rotated copies (audit.log.1
* through .3).
*/
async function migrateAuditLog(): Promise<boolean> {
const sources = [
'audit.log',
'audit.log.1',
'audit.log.2',
'audit.log.3',
];
let moved = false;
for (const name of sources) {
const src = getConfigPath(name);
if (!existsSync(src)) continue;
await ensureStateDir();
const dst = getStatePath(name);
try {
// Same-FS rename is atomic. Falls through to copy if cross-device.
await rename(src, dst);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'EXDEV') {
// Cross-device: copy bytes, then delete source.
const data = await readFile(src);
await writeFile(dst, data);
await unlink(src);
} else {
throw error;
}
}
moved = true;
}
if (moved) {
logger.info('Migrated audit.log to state dir');
}
return moved;
}
/**
* Returns approximate size of legacy data still mixed in the config dir
* (for diagnostics / admin UI). Always returns 0 once migration has run.
*/
export async function getLegacyDataInfo(): Promise<{ adminJsonHasTimestamps: boolean; auditLogInConfigDir: boolean }> {
let adminJsonHasTimestamps = false;
const adminJsonPath = getConfigPath('admin.json');
if (existsSync(adminJsonPath)) {
try {
const raw = await readFile(adminJsonPath, 'utf-8');
const parsed = JSON.parse(raw);
adminJsonHasTimestamps =
'createdAt' in parsed ||
'lastLogin' in parsed ||
'passwordChangedAt' in parsed;
} catch {
/* ignore */
}
}
let auditLogInConfigDir = false;
try {
await stat(getConfigPath('audit.log'));
auditLogInConfigDir = true;
} catch {
/* not present - good */
}
return { adminJsonHasTimestamps, auditLogInConfigDir };
}
+113 -82
View File
@@ -1,9 +1,14 @@
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { readFile, writeFile, rename } from 'node:fs/promises';
import { logger } from '@/lib/logger';
import type { AdminData } from './types';
import {
ensureConfigDir,
ensureStateDir,
getConfigPath,
getStatePath,
assertWritable,
} from './paths';
import type { AdminConfigData, AdminStateData } from './types';
const SCRYPT_KEYLEN = 64;
const SCRYPT_COST = 16384; // 2^14
@@ -11,13 +16,8 @@ const SCRYPT_BLOCK_SIZE = 8;
const SCRYPT_PARALLELIZATION = 1;
const SALT_LENGTH = 32;
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
}
function getAdminJsonPath(): string {
return path.join(getAdminDir(), 'admin.json');
}
const ADMIN_CONFIG_FILE = 'admin.json';
const ADMIN_STATE_FILE = 'admin-state.json';
function hashPassword(password: string): Promise<string> {
return new Promise((resolve, reject) => {
@@ -33,10 +33,8 @@ function hashPassword(password: string): Promise<string> {
function verifyPassword(password: string, stored: string): Promise<boolean> {
return new Promise((resolve, reject) => {
// Support both scrypt format and bcrypt-prefixed values
if (stored.startsWith('$scrypt$')) {
const parts = stored.split('$');
// $scrypt$N=...,r=...,p=...$salt$hash
if (parts.length !== 5) return resolve(false);
const paramStr = parts[2];
const salt = Buffer.from(parts[3], 'base64');
@@ -53,7 +51,6 @@ function verifyPassword(password: string, stored: string): Promise<boolean> {
resolve(timingSafeEqual(derivedKey, storedHash));
});
} else {
// Unknown format
resolve(false);
}
});
@@ -63,50 +60,84 @@ function isHashed(value: string): boolean {
return value.startsWith('$scrypt$') || value.startsWith('$2a$') || value.startsWith('$2b$');
}
async function readAdminData(): Promise<AdminData | null> {
const filePath = getAdminJsonPath();
// ─── Disk I/O ───────────────────────────────────────────────────────────────
async function readJson<T>(filePath: string): Promise<T | null> {
try {
const raw = await readFile(filePath, 'utf-8');
return JSON.parse(raw) as AdminData;
return JSON.parse(raw) as T;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
logger.warn('Failed to read admin.json', { error: error instanceof Error ? error.message : 'Unknown error' });
logger.warn('Failed to read admin file', {
filePath,
error: error instanceof Error ? error.message : 'Unknown error',
});
return null;
}
}
async function writeAdminData(data: AdminData): Promise<void> {
const dir = getAdminDir();
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
const targetPath = getAdminJsonPath();
const tmpPath = targetPath + '.tmp';
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
await rename(tmpPath, targetPath);
async function readConfigData(): Promise<AdminConfigData | null> {
return readJson<AdminConfigData>(getConfigPath(ADMIN_CONFIG_FILE));
}
let cachedAdminData: AdminData | null = null;
async function readStateData(): Promise<AdminStateData | null> {
return readJson<AdminStateData>(getStatePath(ADMIN_STATE_FILE));
}
async function writeConfigData(data: AdminConfigData): Promise<void> {
assertWritable('save admin password');
await ensureConfigDir();
const target = getConfigPath(ADMIN_CONFIG_FILE);
const tmp = target + '.tmp';
await writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8');
await rename(tmp, target);
}
async function writeStateData(data: AdminStateData): Promise<void> {
await ensureStateDir();
const target = getStatePath(ADMIN_STATE_FILE);
const tmp = target + '.tmp';
await writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8');
await rename(tmp, target);
}
// ─── Cache & init ───────────────────────────────────────────────────────────
let cachedConfig: AdminConfigData | null = null;
let cachedState: AdminStateData | null = null;
let initialized = false;
function freshState(): AdminStateData {
const now = new Date().toISOString();
return { createdAt: now, lastLogin: null, passwordChangedAt: now };
}
/**
* Initialize admin password on startup.
* If ADMIN_PASSWORD is cleartext, hash it and write to admin.json.
* Returns true if admin is enabled.
* - If admin.json exists, use it (state file may or may not exist; created on first need).
* - Otherwise, if ADMIN_PASSWORD env var is set, hash and persist it.
* - Otherwise, admin dashboard stays disabled.
*/
export async function initAdminPassword(): Promise<boolean> {
if (initialized) return cachedAdminData !== null;
if (initialized) return cachedConfig !== null;
// Check persistent file first
const existing = await readAdminData();
if (existing) {
cachedAdminData = existing;
const existingConfig = await readConfigData();
if (existingConfig) {
cachedConfig = existingConfig;
cachedState = (await readStateData()) ?? freshState();
if (!(await readStateData())) {
// No state file yet (fresh install or migration); create it.
try {
await writeStateData(cachedState);
} catch {
/* state dir may not be writable yet during early boot probes */
}
}
initialized = true;
logger.info('Admin dashboard enabled (password loaded from admin.json)');
return true;
}
// Check env var
const envPassword = process.env.ADMIN_PASSWORD;
if (!envPassword) {
initialized = true;
@@ -114,33 +145,17 @@ export async function initAdminPassword(): Promise<boolean> {
return false;
}
const hash = isHashed(envPassword) ? envPassword : await hashPassword(envPassword);
cachedConfig = { passwordHash: hash };
cachedState = freshState();
await writeConfigData(cachedConfig);
await writeStateData(cachedState);
initialized = true;
if (isHashed(envPassword)) {
// Already hashed in env - save to file
const data: AdminData = {
passwordHash: envPassword,
createdAt: new Date().toISOString(),
lastLogin: null,
passwordChangedAt: new Date().toISOString(),
};
await writeAdminData(data);
cachedAdminData = data;
initialized = true;
logger.info('Admin password hash saved to admin.json from environment variable');
return true;
}
// Cleartext - hash it
const hash = await hashPassword(envPassword);
const data: AdminData = {
passwordHash: hash,
createdAt: new Date().toISOString(),
lastLogin: null,
passwordChangedAt: new Date().toISOString(),
};
await writeAdminData(data);
cachedAdminData = data;
initialized = true;
} else {
logger.warn('Admin password hashed and saved to admin.json. You may now remove ADMIN_PASSWORD from .env');
}
return true;
}
@@ -148,11 +163,9 @@ export async function initAdminPassword(): Promise<boolean> {
* Verify a password against the stored admin hash.
*/
export async function verifyAdminPassword(password: string): Promise<boolean> {
if (!cachedAdminData) {
cachedAdminData = await readAdminData();
}
if (!cachedAdminData) return false;
return verifyPassword(password, cachedAdminData.passwordHash);
if (!cachedConfig) cachedConfig = await readConfigData();
if (!cachedConfig) return false;
return verifyPassword(password, cachedConfig.passwordHash);
}
/**
@@ -163,14 +176,30 @@ export async function changeAdminPassword(currentPassword: string, newPassword:
if (!valid) return false;
const hash = await hashPassword(newPassword);
if (!cachedAdminData) return false;
cachedConfig = { passwordHash: hash };
await writeConfigData(cachedConfig);
cachedAdminData = {
...cachedAdminData,
passwordHash: hash,
cachedState = {
...(cachedState ?? freshState()),
passwordChangedAt: new Date().toISOString(),
};
await writeAdminData(cachedAdminData);
await writeStateData(cachedState);
return true;
}
/**
* Set the admin password without verifying a current one. Used by the setup
* wizard during initial bootstrap. Refuses to overwrite an existing password.
*/
export async function setInitialAdminPassword(newPassword: string): Promise<boolean> {
const existing = await readConfigData();
if (existing) return false;
const hash = await hashPassword(newPassword);
cachedConfig = { passwordHash: hash };
cachedState = freshState();
await writeConfigData(cachedConfig);
await writeStateData(cachedState);
initialized = true;
return true;
}
@@ -178,29 +207,31 @@ export async function changeAdminPassword(currentPassword: string, newPassword:
* Update the last login timestamp.
*/
export async function updateLastLogin(): Promise<void> {
if (!cachedAdminData) return;
cachedAdminData = {
...cachedAdminData,
if (!cachedConfig) return;
cachedState = {
...(cachedState ?? freshState()),
lastLogin: new Date().toISOString(),
};
await writeAdminData(cachedAdminData);
try {
await writeStateData(cachedState);
} catch (error) {
logger.warn('Failed to update admin last-login state', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
/**
* Check if admin dashboard is enabled (has a password configured).
*/
export function isAdminEnabled(): boolean {
return cachedAdminData !== null;
return cachedConfig !== null;
}
/**
* Get admin metadata (without the hash).
*/
export function getAdminMeta(): { createdAt: string; lastLogin: string | null; passwordChangedAt: string } | null {
if (!cachedAdminData) return null;
return {
createdAt: cachedAdminData.createdAt,
lastLogin: cachedAdminData.lastLogin,
passwordChangedAt: cachedAdminData.passwordChangedAt,
};
export function getAdminMeta(): AdminStateData | null {
if (!cachedConfig) return null;
return cachedState ?? freshState();
}
+126
View File
@@ -0,0 +1,126 @@
import { existsSync } from 'node:fs';
import { mkdir, writeFile, unlink } from 'node:fs/promises';
import path from 'node:path';
import { logger } from '@/lib/logger';
/**
* Admin data directories.
*
* Two dirs intentionally split (issue #226):
* - CONFIG: holds operator-authored state (config.json, policy.json,
* admin.json passwordHash, plugins, themes, branding uploads). Can be
* mounted read-only after initial setup.
* - STATE: holds runtime mutations (admin-state.json with login timestamps,
* audit.log, .setup-token). Always read-write.
*
* Resolution order:
* getConfigDir()
* 1. ADMIN_CONFIG_DIR
* 2. ADMIN_DATA_DIR (legacy)
* 3. <cwd>/data/admin
*
* getStateDir()
* 1. ADMIN_STATE_DIR
* 2. <ADMIN_CONFIG_DIR>/state - if config dir was set explicitly
* 3. <ADMIN_DATA_DIR>/state - back-compat: stays on the legacy volume
* 4. <cwd>/data/admin-state - fresh-install default; matches the
* sibling mount in docker-compose.yml
*
* The legacy ADMIN_DATA_DIR keeps existing single-volume mounts working
* unchanged: everything ends up under it, with state in a `state/` subdir.
* Fresh installs and the docker-compose default keep state in a separate
* sibling dir so the config dir can be mounted :ro after setup.
*/
export function getConfigDir(): string {
return (
process.env.ADMIN_CONFIG_DIR ||
process.env.ADMIN_DATA_DIR ||
path.join(process.cwd(), 'data', 'admin')
);
}
export function getStateDir(): string {
if (process.env.ADMIN_STATE_DIR) return process.env.ADMIN_STATE_DIR;
if (process.env.ADMIN_CONFIG_DIR) {
return path.join(process.env.ADMIN_CONFIG_DIR, 'state');
}
if (process.env.ADMIN_DATA_DIR) {
return path.join(process.env.ADMIN_DATA_DIR, 'state');
}
return path.join(process.cwd(), 'data', 'admin-state');
}
export function getConfigPath(filename: string): string {
return path.join(getConfigDir(), filename);
}
export function getStatePath(filename: string): string {
return path.join(getStateDir(), filename);
}
export async function ensureConfigDir(): Promise<void> {
const dir = getConfigDir();
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
}
export async function ensureStateDir(): Promise<void> {
const dir = getStateDir();
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
}
// ─── Read-only mode ─────────────────────────────────────────────────────────
let cachedReadOnly: boolean | null = null;
/**
* Whether the config dir is locked. Operators set ADMIN_CONFIG_READONLY=true
* after running the setup wizard and remounting the volume :ro.
*
* When true, all writes to the config dir are refused at the application
* layer (cleaner error than a mid-request EROFS).
*/
export function isConfigReadOnly(): boolean {
if (cachedReadOnly !== null) return cachedReadOnly;
const v = (process.env.ADMIN_CONFIG_READONLY || '').toLowerCase();
cachedReadOnly = v === 'true' || v === '1' || v === 'yes';
return cachedReadOnly;
}
/**
* Probe the config dir by writing a temp file. Used to auto-detect RO mounts
* when ADMIN_CONFIG_READONLY is not set explicitly. Run once at startup;
* cheap on local FS, can be slow on networked FS, hence opt-in.
*/
export async function probeConfigReadOnly(): Promise<boolean> {
if (process.env.ADMIN_CONFIG_READONLY) return isConfigReadOnly();
try {
const probe = path.join(getConfigDir(), '.rw-probe');
await writeFile(probe, '');
await unlink(probe);
cachedReadOnly = false;
return false;
} catch {
cachedReadOnly = true;
logger.info('Config dir is read-only (auto-detected)');
return true;
}
}
export class ConfigReadOnlyError extends Error {
constructor(operation: string) {
super(
`Cannot ${operation}: configuration is read-only. ` +
`Remount the config volume read-write or unset ADMIN_CONFIG_READONLY.`
);
this.name = 'ConfigReadOnlyError';
}
}
export function assertWritable(operation: string): void {
if (isConfigReadOnly()) throw new ConfigReadOnlyError(operation);
}
+5 -5
View File
@@ -2,13 +2,10 @@ import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
}
import { getConfigDir, assertWritable } from './paths';
function getPluginConfigDir(): string {
return path.join(getAdminDir(), 'plugin-config');
return path.join(getConfigDir(), 'plugin-config');
}
function configPath(pluginId: string): string {
@@ -41,6 +38,7 @@ export async function getPluginConfig(pluginId: string): Promise<Record<string,
* Set a single config key for a plugin.
*/
export async function setPluginConfig(pluginId: string, key: string, value: unknown): Promise<void> {
assertWritable('update plugin config');
const dir = getPluginConfigDir();
await ensureDir(dir);
@@ -57,6 +55,7 @@ export async function setPluginConfig(pluginId: string, key: string, value: unkn
* Delete a single config key for a plugin.
*/
export async function deletePluginConfigKey(pluginId: string, key: string): Promise<void> {
assertWritable('delete plugin config key');
const config = await getPluginConfig(pluginId);
delete config[key];
@@ -77,5 +76,6 @@ export async function deletePluginConfigKey(pluginId: string, key: string): Prom
* Delete all config for a plugin (used when uninstalling).
*/
export async function deleteAllPluginConfig(pluginId: string): Promise<void> {
assertWritable('delete plugin config');
try { await unlink(configPath(pluginId)); } catch { /* ok if missing */ }
}
+9 -6
View File
@@ -3,17 +3,14 @@ import { existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { logger } from '@/lib/logger';
function getAdminDir(): string {
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
}
import { getConfigDir, assertWritable } from './paths';
function getPluginsDir(): string {
return path.join(getAdminDir(), 'plugins');
return path.join(getConfigDir(), 'plugins');
}
function getThemesDir(): string {
return path.join(getAdminDir(), 'themes');
return path.join(getConfigDir(), 'themes');
}
// ─── Types ───────────────────────────────────────────────────
@@ -141,6 +138,7 @@ export async function savePlugin(
plugin: ServerPlugin,
code: string,
): Promise<void> {
assertWritable('install plugin');
const dir = getPluginsDir();
await ensureDir(dir);
@@ -171,6 +169,7 @@ export async function savePlugin(
}
export async function updatePluginMeta(id: string, updates: Partial<Pick<ServerPlugin, 'enabled' | 'forceEnabled'>>): Promise<ServerPlugin | null> {
assertWritable('update plugin metadata');
const registry = await getPluginRegistry();
const idx = registry.plugins.findIndex(p => p.id === id);
if (idx < 0) return null;
@@ -181,6 +180,7 @@ export async function updatePluginMeta(id: string, updates: Partial<Pick<ServerP
}
export async function deletePlugin(id: string): Promise<boolean> {
assertWritable('delete plugin');
const registry = await getPluginRegistry();
const idx = registry.plugins.findIndex(p => p.id === id);
if (idx < 0) return false;
@@ -221,6 +221,7 @@ export async function saveTheme(
theme: ServerTheme,
css: string,
): Promise<void> {
assertWritable('install theme');
const dir = getThemesDir();
await ensureDir(dir);
@@ -240,6 +241,7 @@ export async function saveTheme(
}
export async function updateThemeMeta(id: string, updates: Partial<Pick<ServerTheme, 'enabled' | 'forceEnabled'>>): Promise<ServerTheme | null> {
assertWritable('update theme metadata');
const registry = await getThemeRegistry();
const idx = registry.themes.findIndex(t => t.id === id);
if (idx < 0) return null;
@@ -250,6 +252,7 @@ export async function updateThemeMeta(id: string, updates: Partial<Pick<ServerTh
}
export async function deleteTheme(id: string): Promise<boolean> {
assertWritable('delete theme');
const registry = await getThemeRegistry();
const idx = registry.themes.findIndex(t => t.id === id);
if (idx < 0) return false;
+2 -2
View File
@@ -1,7 +1,7 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
import { readFileEnv } from '@/lib/read-file-env';
import { getSessionSecret } from '@/lib/auth/session-secret';
import { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types';
import type { AdminSessionPayload } from './types';
@@ -12,7 +12,7 @@ const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32;
function getKey(): Buffer {
const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
const secret = getSessionSecret();
if (!secret) throw new Error('SESSION_SECRET not configured');
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
+19 -1
View File
@@ -1,12 +1,30 @@
// Admin dashboard types
export interface AdminData {
/**
* Operator-authored admin record. Lives in admin.json under the config dir
* and can be mounted read-only after setup. Only the password hash itself
* is config; mutable timestamps live in AdminStateData.
*/
export interface AdminConfigData {
passwordHash: string;
}
/**
* Runtime-mutable admin record. Lives in admin-state.json under the state
* dir. Updated on every login and password change, so it must stay writable.
*/
export interface AdminStateData {
createdAt: string;
lastLogin: string | null;
passwordChangedAt: string;
}
/**
* Combined view used by getAdminMeta() and tests. Constructed by merging
* admin.json + admin-state.json at read time.
*/
export interface AdminData extends AdminConfigData, AdminStateData {}
export interface AdminSessionPayload {
role: 'admin';
iat: number;
+2 -2
View File
@@ -1,6 +1,6 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
import { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env';
import { getSessionSecret } from '@/lib/auth/session-secret';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
@@ -9,7 +9,7 @@ const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32;
function getKey(): Buffer {
const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
const secret = getSessionSecret();
if (!secret) throw new Error('SESSION_SECRET not configured');
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
+31
View File
@@ -0,0 +1,31 @@
import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env';
/**
* Resolve the session secret from any of the supported sources, in priority
* order:
* 1. SESSION_SECRET env var
* 2. SESSION_SECRET_FILE-pointed file
* 3. Admin override in config.json (set by the setup wizard)
*
* Returns an empty string when nothing is configured. Callers must treat
* empty as "feature disabled" rather than crashing.
*
* The configManager fallback exists so the web installer can persist the
* secret without touching .env files. It only takes effect if the env vars
* aren't set, so existing deployments aren't affected.
*/
export function getSessionSecret(): string {
const fromEnv = process.env.SESSION_SECRET;
if (fromEnv) return fromEnv;
const fromFile = readFileEnv(process.env.SESSION_SECRET_FILE);
if (fromFile) return fromFile;
const fromAdmin = configManager.get<string>('sessionSecret', '');
return fromAdmin || '';
}
export function hasSessionSecret(): boolean {
return getSessionSecret().length > 0;
}
+2 -2
View File
@@ -3,14 +3,14 @@ import { readFile, writeFile, unlink, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env';
import { getSessionSecret } from '@/lib/auth/session-secret';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
function getKey(): Buffer {
const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
const secret = getSessionSecret();
if (!secret) throw new Error('SESSION_SECRET not configured');
return createHash('sha256').update(secret).digest();
}
+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 };
}
}
+58 -4
View File
@@ -2,6 +2,8 @@ import { type NextRequest, NextResponse } from "next/server";
import createIntlMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
import { getEnabledPluginFrameOrigins } from "./lib/admin/csp-frame-origins";
import { configManager } from "./lib/admin/config-manager";
import { detectSetupState } from "./lib/setup/state";
const intlMiddleware = createIntlMiddleware(routing);
@@ -11,8 +13,59 @@ const intlMiddleware = createIntlMiddleware(routing);
// requests for API routes, Next internals and static assets.
const PROXY_SKIP_PATTERN = /^\/(?:api|_next)(?:\/|$)|\.[^/]+$/;
function isSetupPath(pathname: string): boolean {
return (
pathname === "/setup" ||
pathname.startsWith("/setup/") ||
pathname.startsWith("/api/setup")
);
}
export async function proxy(request: NextRequest) {
if (PROXY_SKIP_PATTERN.test(request.nextUrl.pathname)) {
// Resolve setup state before deciding what to skip. The first call after
// boot triggers the config load; subsequent calls are in-memory.
await configManager.ensureLoaded();
const setupState = detectSetupState();
const pathname = request.nextUrl.pathname;
if (setupState === "bootstrap") {
// Wizard active. Redirect HTML pages to /setup; let asset/internal
// requests through so the wizard UI can render. Block non-setup APIs
// with a 503 so cached SPA code doesn't silently call them.
const allowed =
isSetupPath(pathname) ||
pathname === "/api/health" ||
pathname.startsWith("/_next/") ||
pathname.startsWith("/branding/") ||
/\.[^/]+$/.test(pathname);
if (!allowed) {
if (pathname.startsWith("/api/")) {
return new NextResponse(
JSON.stringify({ error: "setup_required", message: "Initial setup has not completed." }),
{ status: 503, headers: { "content-type": "application/json" } },
);
}
const url = request.nextUrl.clone();
url.pathname = "/setup";
url.search = request.nextUrl.search;
return NextResponse.redirect(url);
}
} else if (isSetupPath(pathname)) {
// Configured / env-managed: wizard is no longer reachable.
// - HTML /setup pages → redirect to admin login so users who reload
// the URL after setup don't see a dead "Not Found" page.
// - /api/setup/* → 404 (no reason to expose these endpoints).
if (pathname.startsWith("/api/setup")) {
return new NextResponse("Not Found", { status: 404 });
}
const url = request.nextUrl.clone();
url.pathname = "/admin/login";
url.search = "";
return NextResponse.redirect(url);
}
if (PROXY_SKIP_PATTERN.test(pathname)) {
return NextResponse.next();
}
@@ -50,9 +103,10 @@ export async function proxy(request: NextRequest) {
`media-src 'self' blob:`,
].join("; ");
// Skip intl middleware for /admin routes - they have their own layout
const pathname = request.nextUrl.pathname;
// Skip intl middleware for /admin and /setup routes - they have their
// own layout outside the [locale] tree.
const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/');
const isSetupRoute = pathname === '/setup' || pathname.startsWith('/setup/');
// When localePrefix is 'always', paths that already have a locale prefix
// (e.g. /en/settings) should not be re-processed by the intl middleware -
@@ -63,7 +117,7 @@ export async function proxy(request: NextRequest) {
);
let intlResponse: ReturnType<typeof intlMiddleware> | null = null;
if (!isAdminRoute && !hasLocalePrefix) {
if (!isAdminRoute && !isSetupRoute && !hasLocalePrefix) {
try {
intlResponse = intlMiddleware(request);
} catch (error) {