feat: web setup wizard + admin config/state dir split (#226)
This commit is contained in:
+21
-4
@@ -78,10 +78,27 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
|||||||
# Admin Dashboard Data
|
# Admin Dashboard Data
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Directory for admin dashboard state: config overrides, admin password hash,
|
# Admin data is split across two directories so the config volume can be
|
||||||
# installed plugins/themes, and audit logs (default: ./data/admin).
|
# mounted read-only after the setup wizard completes (see issue #226).
|
||||||
# For Docker, the default resolves to /app/data/admin - mount a persistent
|
#
|
||||||
# volume there (see docker-compose.yml).
|
# 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
|
# ADMIN_DATA_DIR=./data/admin
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ RUN apk upgrade --no-cache && \
|
|||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
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
|
USER nextjs
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
@@ -47,13 +47,13 @@ export default function AdminLoginPage() {
|
|||||||
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
<div className="flex flex-col items-center mb-8">
|
<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 ? (
|
{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" />
|
<Shield className="w-6 h-6 text-primary" />
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<h1 className="text-xl font-semibold text-foreground">Admin Dashboard</h1>
|
<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>
|
<p className="text-sm text-muted-foreground mt-1">Enter your admin password to continue</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import { getRequiredConfig } from '@/lib/oauth/token-exchange';
|
|||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||||
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
|
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
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_COOKIE = 'sso_pending';
|
||||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
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 });
|
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
|
||||||
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
|
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
|
||||||
|
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime configuration endpoint
|
* Runtime configuration endpoint
|
||||||
@@ -35,8 +35,8 @@ export async function GET() {
|
|||||||
oauthOnly,
|
oauthOnly,
|
||||||
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
oauthClientId: configManager.get<string>('oauthClientId', ''),
|
||||||
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
|
||||||
rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
|
rememberMeEnabled: hasSessionSecret(),
|
||||||
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
|
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
|
||||||
stalwartFeaturesEnabled,
|
stalwartFeaturesEnabled,
|
||||||
devMode: configManager.get<boolean>('devMode', false),
|
devMode: configManager.get<boolean>('devMode', false),
|
||||||
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
|
|||||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||||
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
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';
|
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||||
|
|
||||||
function classifyError(error: unknown): { message: string; status: number } {
|
function classifyError(error: unknown): { message: string; status: number } {
|
||||||
@@ -50,7 +50,10 @@ function classifyError(error: unknown): { message: string; status: number } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isEnabled(): boolean {
|
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. */
|
/** Strip trailing slashes so differently-formatted URLs still match. */
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -281,7 +281,7 @@ export function EmailComposer({
|
|||||||
const currentIdentity = selectedIdentityId
|
const currentIdentity = selectedIdentityId
|
||||||
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
|
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
|
||||||
: 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
|
// identity's signature so replies (which auto-select a matching alias) still
|
||||||
// populate the user's signature.
|
// populate the user's signature.
|
||||||
const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature)
|
const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature)
|
||||||
|
|||||||
@@ -2296,7 +2296,7 @@ export function EmailViewer({
|
|||||||
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
|
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
|
||||||
// Per RFC 8621 § 4.1.4, when a message has only one alternative the server
|
// 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
|
// 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.
|
// collapses newlines and skips linkification, so route by the part's type.
|
||||||
const htmlPart = email.htmlBody[0];
|
const htmlPart = email.htmlBody[0];
|
||||||
if (htmlPart.type && htmlPart.type.toLowerCase() !== 'text/html') {
|
if (htmlPart.type && htmlPart.type.toLowerCase() !== 'text/html') {
|
||||||
@@ -2691,7 +2691,7 @@ export function EmailViewer({
|
|||||||
// double re-inverting images nested inside those containers.
|
// double re-inverting images nested inside those containers.
|
||||||
// Nested bgcolor containers must NOT add another invert layer: each filter
|
// Nested bgcolor containers must NOT add another invert layer: each filter
|
||||||
// toggles the inversion, so an odd number of stacked filters (e.g. body +
|
// 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
|
// i.e. light-on-light. The second rule disables filter on bgcolor-like
|
||||||
// elements that are descendants of another bgcolor-like element.
|
// elements that are descendants of another bgcolor-like element.
|
||||||
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
|
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
|
||||||
@@ -2888,7 +2888,7 @@ export function EmailViewer({
|
|||||||
// Re-invert emoji glyphs so they keep their original colors. The
|
// Re-invert emoji glyphs so they keep their original colors. The
|
||||||
// body's invert filter flips colored emoji (yellow smiley → blue,
|
// body's invert filter flips colored emoji (yellow smiley → blue,
|
||||||
// red heart → cyan, etc.). Wrap each emoji run in a span that
|
// 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
|
// emojis inside a double-inverted bgcolor container already render
|
||||||
// at their original colors.
|
// at their original colors.
|
||||||
let emojiRe: RegExp;
|
let emojiRe: RegExp;
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ function EmailCard({
|
|||||||
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
|
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
|
||||||
// Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting).
|
// 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.
|
// 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.
|
// in that case there is no real plain-text alternative, so always render the HTML.
|
||||||
const textPartId = email.textBody?.[0]?.partId;
|
const textPartId = email.textBody?.[0]?.partId;
|
||||||
const htmlPartId = email.htmlBody[0].partId;
|
const htmlPartId = email.htmlBody[0].partId;
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
|
|||||||
setDefaultAccount(accountId);
|
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
|
// primaryIdentity can be an alias (e.g. info@korazo.net) that differs from
|
||||||
// the actually logged-in account (info@linusrath.de).
|
// the actually logged-in account (info@linusrath.de).
|
||||||
const displayName = activeAccount?.displayName || activeAccount?.label || "";
|
const displayName = activeAccount?.displayName || activeAccount?.label || "";
|
||||||
|
|||||||
+7
-1
@@ -11,8 +11,13 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
# Encrypted user settings (SETTINGS_DATA_DIR).
|
# Encrypted user settings (SETTINGS_DATA_DIR).
|
||||||
- bulwark-settings:/app/data/settings
|
- 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
|
- 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).
|
# 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.
|
# Persisting this preserves the admin's consent choice and stable instance id across upgrades.
|
||||||
- bulwark-telemetry:/app/data/telemetry
|
- bulwark-telemetry:/app/data/telemetry
|
||||||
@@ -35,4 +40,5 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
bulwark-settings:
|
bulwark-settings:
|
||||||
bulwark-admin:
|
bulwark-admin:
|
||||||
|
bulwark-admin-state:
|
||||||
bulwark-telemetry:
|
bulwark-telemetry:
|
||||||
|
|||||||
+31
-3
@@ -1,6 +1,9 @@
|
|||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { configManager } from "./lib/admin/config-manager";
|
import { configManager } from "./lib/admin/config-manager";
|
||||||
import { initAdminPassword } from "./lib/admin/password";
|
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(
|
const pkg = JSON.parse(
|
||||||
readFileSync(`${process.cwd()}/package.json`, "utf-8")
|
readFileSync(`${process.cwd()}/package.json`, "utf-8")
|
||||||
@@ -8,11 +11,36 @@ const pkg = JSON.parse(
|
|||||||
const current: string = pkg.version ?? "0.0.0";
|
const current: string = pkg.version ?? "0.0.0";
|
||||||
console.info(`Bulwark Webmail v${current}`);
|
console.info(`Bulwark Webmail v${current}`);
|
||||||
|
|
||||||
// Initialize admin config and password bootstrap
|
// Initialize admin config and password bootstrap. Migration runs first so
|
||||||
configManager.load()
|
// existing v1 layouts are split before anything reads admin.json.
|
||||||
|
migrateLegacyAdminLayout()
|
||||||
|
.then(() => configManager.load())
|
||||||
.then(() => initAdminPassword())
|
.then(() => initAdminPassword())
|
||||||
.then(() => {
|
.then(async () => {
|
||||||
console.info("Admin dashboard initialized");
|
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 () => {
|
.then(async () => {
|
||||||
// Anonymous telemetry - on by default. Admins can disable via the
|
// Anonymous telemetry - on by default. Admins can disable via the
|
||||||
|
|||||||
@@ -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
|
* 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
|
* (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;
|
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
|
* We walk recent resource-timing entries and treat a single h2/h3 sighting
|
||||||
* as a positive signal. Cross-origin entries may report an empty
|
* as a positive signal. Cross-origin entries may report an empty
|
||||||
* `nextHopProtocol` without `Timing-Allow-Origin`, in which case we
|
* `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 {
|
export function isHttp2Available(): boolean {
|
||||||
if (typeof performance === 'undefined') return false;
|
if (typeof performance === 'undefined') return false;
|
||||||
|
|||||||
+7
-14
@@ -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 { existsSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
import { ensureStateDir, getStatePath } from './paths';
|
||||||
import type { AuditEntry } from './types';
|
import type { AuditEntry } from './types';
|
||||||
|
|
||||||
const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB
|
const MAX_LOG_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||||
const MAX_ROTATIONS = 3;
|
const MAX_ROTATIONS = 3;
|
||||||
|
const AUDIT_LOG_FILE = 'audit.log';
|
||||||
function getAdminDir(): string {
|
|
||||||
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAuditLogPath(): string {
|
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> {
|
export async function auditLog(action: string, detail: Record<string, unknown>, ip: string): Promise<void> {
|
||||||
const dir = getAdminDir();
|
await ensureStateDir();
|
||||||
if (!existsSync(dir)) {
|
|
||||||
await mkdir(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry: AuditEntry = {
|
const entry: AuditEntry = {
|
||||||
ts: new Date().toISOString(),
|
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 }> {
|
export async function readAuditLog(page: number = 1, limit: number = 50, actionFilter?: string): Promise<{ entries: AuditEntry[]; total: number }> {
|
||||||
const logPath = getAuditLogPath();
|
const logPath = getAuditLogPath();
|
||||||
try {
|
try {
|
||||||
const { readFile } = await import('node:fs/promises');
|
|
||||||
const content = await readFile(logPath, 'utf-8');
|
const content = await readFile(logPath, 'utf-8');
|
||||||
const lines = content.trim().split('\n').filter(Boolean);
|
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;
|
const total = entries.length;
|
||||||
// Return newest first
|
|
||||||
entries.reverse();
|
entries.reverse();
|
||||||
const start = (page - 1) * limit;
|
const start = (page - 1) * limit;
|
||||||
return { entries: entries.slice(start, start + limit), total };
|
return { entries: entries.slice(start, start + limit), total };
|
||||||
|
|||||||
+36
-14
@@ -1,13 +1,8 @@
|
|||||||
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
import { readFile, writeFile, rename } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
|
import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
|
||||||
|
import { ensureConfigDir, getConfigPath, assertWritable } from './paths';
|
||||||
function getAdminDir(): string {
|
|
||||||
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEnvValue(value: string, type: string): unknown {
|
function parseEnvValue(value: string, type: string): unknown {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -127,6 +122,7 @@ class ConfigManager {
|
|||||||
* Update admin config overrides. Writes to disk.
|
* Update admin config overrides. Writes to disk.
|
||||||
*/
|
*/
|
||||||
async setAdminConfig(updates: Record<string, unknown>): Promise<void> {
|
async setAdminConfig(updates: Record<string, unknown>): Promise<void> {
|
||||||
|
assertWritable('update admin config');
|
||||||
Object.assign(this.adminConfig, updates);
|
Object.assign(this.adminConfig, updates);
|
||||||
await this.writeJsonFile('config.json', this.adminConfig);
|
await this.writeJsonFile('config.json', this.adminConfig);
|
||||||
}
|
}
|
||||||
@@ -135,10 +131,29 @@ class ConfigManager {
|
|||||||
* Remove an admin override, reverting to env/default.
|
* Remove an admin override, reverting to env/default.
|
||||||
*/
|
*/
|
||||||
async removeAdminOverride(key: string): Promise<void> {
|
async removeAdminOverride(key: string): Promise<void> {
|
||||||
|
assertWritable('remove admin override');
|
||||||
delete this.adminConfig[key];
|
delete this.adminConfig[key];
|
||||||
await this.writeJsonFile('config.json', this.adminConfig);
|
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.
|
* Get the current settings policy.
|
||||||
*/
|
*/
|
||||||
@@ -150,6 +165,7 @@ class ConfigManager {
|
|||||||
* Update the settings policy. Writes to disk.
|
* Update the settings policy. Writes to disk.
|
||||||
*/
|
*/
|
||||||
async setPolicy(policy: SettingsPolicy): Promise<void> {
|
async setPolicy(policy: SettingsPolicy): Promise<void> {
|
||||||
|
assertWritable('update settings policy');
|
||||||
this.policyCache = {
|
this.policyCache = {
|
||||||
...DEFAULT_POLICY,
|
...DEFAULT_POLICY,
|
||||||
...policy,
|
...policy,
|
||||||
@@ -167,7 +183,7 @@ class ConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async readJsonFile(filename: string): Promise<Record<string, unknown> | null> {
|
private async readJsonFile(filename: string): Promise<Record<string, unknown> | null> {
|
||||||
const filePath = path.join(getAdminDir(), filename);
|
const filePath = getConfigPath(filename);
|
||||||
try {
|
try {
|
||||||
const raw = await readFile(filePath, 'utf-8');
|
const raw = await readFile(filePath, 'utf-8');
|
||||||
return JSON.parse(raw);
|
return JSON.parse(raw);
|
||||||
@@ -179,15 +195,21 @@ class ConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async writeJsonFile(filename: string, data: Record<string, unknown>): Promise<void> {
|
private async writeJsonFile(filename: string, data: Record<string, unknown>): Promise<void> {
|
||||||
const dir = getAdminDir();
|
await ensureConfigDir();
|
||||||
if (!existsSync(dir)) {
|
const targetPath = getConfigPath(filename);
|
||||||
await mkdir(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
const targetPath = path.join(dir, filename);
|
|
||||||
const tmpPath = targetPath + '.tmp';
|
const tmpPath = targetPath + '.tmp';
|
||||||
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
|
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||||
await rename(tmpPath, targetPath);
|
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());
|
||||||
|
|||||||
@@ -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
@@ -1,9 +1,14 @@
|
|||||||
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
|
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
import { readFile, writeFile, rename } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { logger } from '@/lib/logger';
|
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_KEYLEN = 64;
|
||||||
const SCRYPT_COST = 16384; // 2^14
|
const SCRYPT_COST = 16384; // 2^14
|
||||||
@@ -11,13 +16,8 @@ const SCRYPT_BLOCK_SIZE = 8;
|
|||||||
const SCRYPT_PARALLELIZATION = 1;
|
const SCRYPT_PARALLELIZATION = 1;
|
||||||
const SALT_LENGTH = 32;
|
const SALT_LENGTH = 32;
|
||||||
|
|
||||||
function getAdminDir(): string {
|
const ADMIN_CONFIG_FILE = 'admin.json';
|
||||||
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
|
const ADMIN_STATE_FILE = 'admin-state.json';
|
||||||
}
|
|
||||||
|
|
||||||
function getAdminJsonPath(): string {
|
|
||||||
return path.join(getAdminDir(), 'admin.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
function hashPassword(password: string): Promise<string> {
|
function hashPassword(password: string): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -33,10 +33,8 @@ function hashPassword(password: string): Promise<string> {
|
|||||||
|
|
||||||
function verifyPassword(password: string, stored: string): Promise<boolean> {
|
function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Support both scrypt format and bcrypt-prefixed values
|
|
||||||
if (stored.startsWith('$scrypt$')) {
|
if (stored.startsWith('$scrypt$')) {
|
||||||
const parts = stored.split('$');
|
const parts = stored.split('$');
|
||||||
// $scrypt$N=...,r=...,p=...$salt$hash
|
|
||||||
if (parts.length !== 5) return resolve(false);
|
if (parts.length !== 5) return resolve(false);
|
||||||
const paramStr = parts[2];
|
const paramStr = parts[2];
|
||||||
const salt = Buffer.from(parts[3], 'base64');
|
const salt = Buffer.from(parts[3], 'base64');
|
||||||
@@ -53,7 +51,6 @@ function verifyPassword(password: string, stored: string): Promise<boolean> {
|
|||||||
resolve(timingSafeEqual(derivedKey, storedHash));
|
resolve(timingSafeEqual(derivedKey, storedHash));
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Unknown format
|
|
||||||
resolve(false);
|
resolve(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -63,50 +60,84 @@ function isHashed(value: string): boolean {
|
|||||||
return value.startsWith('$scrypt$') || value.startsWith('$2a$') || value.startsWith('$2b$');
|
return value.startsWith('$scrypt$') || value.startsWith('$2a$') || value.startsWith('$2b$');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readAdminData(): Promise<AdminData | null> {
|
// ─── Disk I/O ───────────────────────────────────────────────────────────────
|
||||||
const filePath = getAdminJsonPath();
|
|
||||||
|
async function readJson<T>(filePath: string): Promise<T | null> {
|
||||||
try {
|
try {
|
||||||
const raw = await readFile(filePath, 'utf-8');
|
const raw = await readFile(filePath, 'utf-8');
|
||||||
return JSON.parse(raw) as AdminData;
|
return JSON.parse(raw) as T;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeAdminData(data: AdminData): Promise<void> {
|
async function readConfigData(): Promise<AdminConfigData | null> {
|
||||||
const dir = getAdminDir();
|
return readJson<AdminConfigData>(getConfigPath(ADMIN_CONFIG_FILE));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
let initialized = false;
|
||||||
|
|
||||||
|
function freshState(): AdminStateData {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return { createdAt: now, lastLogin: null, passwordChangedAt: now };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize admin password on startup.
|
* Initialize admin password on startup.
|
||||||
* If ADMIN_PASSWORD is cleartext, hash it and write to admin.json.
|
* - If admin.json exists, use it (state file may or may not exist; created on first need).
|
||||||
* Returns true if admin is enabled.
|
* - Otherwise, if ADMIN_PASSWORD env var is set, hash and persist it.
|
||||||
|
* - Otherwise, admin dashboard stays disabled.
|
||||||
*/
|
*/
|
||||||
export async function initAdminPassword(): Promise<boolean> {
|
export async function initAdminPassword(): Promise<boolean> {
|
||||||
if (initialized) return cachedAdminData !== null;
|
if (initialized) return cachedConfig !== null;
|
||||||
|
|
||||||
// Check persistent file first
|
const existingConfig = await readConfigData();
|
||||||
const existing = await readAdminData();
|
if (existingConfig) {
|
||||||
if (existing) {
|
cachedConfig = existingConfig;
|
||||||
cachedAdminData = existing;
|
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;
|
initialized = true;
|
||||||
logger.info('Admin dashboard enabled (password loaded from admin.json)');
|
logger.info('Admin dashboard enabled (password loaded from admin.json)');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check env var
|
|
||||||
const envPassword = process.env.ADMIN_PASSWORD;
|
const envPassword = process.env.ADMIN_PASSWORD;
|
||||||
if (!envPassword) {
|
if (!envPassword) {
|
||||||
initialized = true;
|
initialized = true;
|
||||||
@@ -114,33 +145,17 @@ export async function initAdminPassword(): Promise<boolean> {
|
|||||||
return false;
|
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)) {
|
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');
|
logger.info('Admin password hash saved to admin.json from environment variable');
|
||||||
return true;
|
} else {
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
logger.warn('Admin password hashed and saved to admin.json. You may now remove ADMIN_PASSWORD from .env');
|
logger.warn('Admin password hashed and saved to admin.json. You may now remove ADMIN_PASSWORD from .env');
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,11 +163,9 @@ export async function initAdminPassword(): Promise<boolean> {
|
|||||||
* Verify a password against the stored admin hash.
|
* Verify a password against the stored admin hash.
|
||||||
*/
|
*/
|
||||||
export async function verifyAdminPassword(password: string): Promise<boolean> {
|
export async function verifyAdminPassword(password: string): Promise<boolean> {
|
||||||
if (!cachedAdminData) {
|
if (!cachedConfig) cachedConfig = await readConfigData();
|
||||||
cachedAdminData = await readAdminData();
|
if (!cachedConfig) return false;
|
||||||
}
|
return verifyPassword(password, cachedConfig.passwordHash);
|
||||||
if (!cachedAdminData) return false;
|
|
||||||
return verifyPassword(password, cachedAdminData.passwordHash);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -163,14 +176,30 @@ export async function changeAdminPassword(currentPassword: string, newPassword:
|
|||||||
if (!valid) return false;
|
if (!valid) return false;
|
||||||
|
|
||||||
const hash = await hashPassword(newPassword);
|
const hash = await hashPassword(newPassword);
|
||||||
if (!cachedAdminData) return false;
|
cachedConfig = { passwordHash: hash };
|
||||||
|
await writeConfigData(cachedConfig);
|
||||||
|
|
||||||
cachedAdminData = {
|
cachedState = {
|
||||||
...cachedAdminData,
|
...(cachedState ?? freshState()),
|
||||||
passwordHash: hash,
|
|
||||||
passwordChangedAt: new Date().toISOString(),
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,29 +207,31 @@ export async function changeAdminPassword(currentPassword: string, newPassword:
|
|||||||
* Update the last login timestamp.
|
* Update the last login timestamp.
|
||||||
*/
|
*/
|
||||||
export async function updateLastLogin(): Promise<void> {
|
export async function updateLastLogin(): Promise<void> {
|
||||||
if (!cachedAdminData) return;
|
if (!cachedConfig) return;
|
||||||
cachedAdminData = {
|
cachedState = {
|
||||||
...cachedAdminData,
|
...(cachedState ?? freshState()),
|
||||||
lastLogin: new Date().toISOString(),
|
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).
|
* Check if admin dashboard is enabled (has a password configured).
|
||||||
*/
|
*/
|
||||||
export function isAdminEnabled(): boolean {
|
export function isAdminEnabled(): boolean {
|
||||||
return cachedAdminData !== null;
|
return cachedConfig !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get admin metadata (without the hash).
|
* Get admin metadata (without the hash).
|
||||||
*/
|
*/
|
||||||
export function getAdminMeta(): { createdAt: string; lastLogin: string | null; passwordChangedAt: string } | null {
|
export function getAdminMeta(): AdminStateData | null {
|
||||||
if (!cachedAdminData) return null;
|
if (!cachedConfig) return null;
|
||||||
return {
|
return cachedState ?? freshState();
|
||||||
createdAt: cachedAdminData.createdAt,
|
|
||||||
lastLogin: cachedAdminData.lastLogin,
|
|
||||||
passwordChangedAt: cachedAdminData.passwordChangedAt,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -2,13 +2,10 @@ import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
|
|||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
import { getConfigDir, assertWritable } from './paths';
|
||||||
function getAdminDir(): string {
|
|
||||||
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPluginConfigDir(): string {
|
function getPluginConfigDir(): string {
|
||||||
return path.join(getAdminDir(), 'plugin-config');
|
return path.join(getConfigDir(), 'plugin-config');
|
||||||
}
|
}
|
||||||
|
|
||||||
function configPath(pluginId: string): string {
|
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.
|
* Set a single config key for a plugin.
|
||||||
*/
|
*/
|
||||||
export async function setPluginConfig(pluginId: string, key: string, value: unknown): Promise<void> {
|
export async function setPluginConfig(pluginId: string, key: string, value: unknown): Promise<void> {
|
||||||
|
assertWritable('update plugin config');
|
||||||
const dir = getPluginConfigDir();
|
const dir = getPluginConfigDir();
|
||||||
await ensureDir(dir);
|
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.
|
* Delete a single config key for a plugin.
|
||||||
*/
|
*/
|
||||||
export async function deletePluginConfigKey(pluginId: string, key: string): Promise<void> {
|
export async function deletePluginConfigKey(pluginId: string, key: string): Promise<void> {
|
||||||
|
assertWritable('delete plugin config key');
|
||||||
const config = await getPluginConfig(pluginId);
|
const config = await getPluginConfig(pluginId);
|
||||||
delete config[key];
|
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).
|
* Delete all config for a plugin (used when uninstalling).
|
||||||
*/
|
*/
|
||||||
export async function deleteAllPluginConfig(pluginId: string): Promise<void> {
|
export async function deleteAllPluginConfig(pluginId: string): Promise<void> {
|
||||||
|
assertWritable('delete plugin config');
|
||||||
try { await unlink(configPath(pluginId)); } catch { /* ok if missing */ }
|
try { await unlink(configPath(pluginId)); } catch { /* ok if missing */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,14 @@ import { existsSync } from 'node:fs';
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
import { getConfigDir, assertWritable } from './paths';
|
||||||
function getAdminDir(): string {
|
|
||||||
return process.env.ADMIN_DATA_DIR || path.join(process.cwd(), 'data', 'admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPluginsDir(): string {
|
function getPluginsDir(): string {
|
||||||
return path.join(getAdminDir(), 'plugins');
|
return path.join(getConfigDir(), 'plugins');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getThemesDir(): string {
|
function getThemesDir(): string {
|
||||||
return path.join(getAdminDir(), 'themes');
|
return path.join(getConfigDir(), 'themes');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────
|
// ─── Types ───────────────────────────────────────────────────
|
||||||
@@ -141,6 +138,7 @@ export async function savePlugin(
|
|||||||
plugin: ServerPlugin,
|
plugin: ServerPlugin,
|
||||||
code: string,
|
code: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
assertWritable('install plugin');
|
||||||
const dir = getPluginsDir();
|
const dir = getPluginsDir();
|
||||||
await ensureDir(dir);
|
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> {
|
export async function updatePluginMeta(id: string, updates: Partial<Pick<ServerPlugin, 'enabled' | 'forceEnabled'>>): Promise<ServerPlugin | null> {
|
||||||
|
assertWritable('update plugin metadata');
|
||||||
const registry = await getPluginRegistry();
|
const registry = await getPluginRegistry();
|
||||||
const idx = registry.plugins.findIndex(p => p.id === id);
|
const idx = registry.plugins.findIndex(p => p.id === id);
|
||||||
if (idx < 0) return null;
|
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> {
|
export async function deletePlugin(id: string): Promise<boolean> {
|
||||||
|
assertWritable('delete plugin');
|
||||||
const registry = await getPluginRegistry();
|
const registry = await getPluginRegistry();
|
||||||
const idx = registry.plugins.findIndex(p => p.id === id);
|
const idx = registry.plugins.findIndex(p => p.id === id);
|
||||||
if (idx < 0) return false;
|
if (idx < 0) return false;
|
||||||
@@ -221,6 +221,7 @@ export async function saveTheme(
|
|||||||
theme: ServerTheme,
|
theme: ServerTheme,
|
||||||
css: string,
|
css: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
assertWritable('install theme');
|
||||||
const dir = getThemesDir();
|
const dir = getThemesDir();
|
||||||
await ensureDir(dir);
|
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> {
|
export async function updateThemeMeta(id: string, updates: Partial<Pick<ServerTheme, 'enabled' | 'forceEnabled'>>): Promise<ServerTheme | null> {
|
||||||
|
assertWritable('update theme metadata');
|
||||||
const registry = await getThemeRegistry();
|
const registry = await getThemeRegistry();
|
||||||
const idx = registry.themes.findIndex(t => t.id === id);
|
const idx = registry.themes.findIndex(t => t.id === id);
|
||||||
if (idx < 0) return null;
|
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> {
|
export async function deleteTheme(id: string): Promise<boolean> {
|
||||||
|
assertWritable('delete theme');
|
||||||
const registry = await getThemeRegistry();
|
const registry = await getThemeRegistry();
|
||||||
const idx = registry.themes.findIndex(t => t.id === id);
|
const idx = registry.themes.findIndex(t => t.id === id);
|
||||||
if (idx < 0) return false;
|
if (idx < 0) return false;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
|
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 { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types';
|
||||||
import type { AdminSessionPayload } from './types';
|
import type { AdminSessionPayload } from './types';
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ const TAG_LENGTH = 16;
|
|||||||
const MIN_SECRET_LENGTH = 32;
|
const MIN_SECRET_LENGTH = 32;
|
||||||
|
|
||||||
function getKey(): Buffer {
|
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) throw new Error('SESSION_SECRET not configured');
|
||||||
if (secret.length < MIN_SECRET_LENGTH) {
|
if (secret.length < MIN_SECRET_LENGTH) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
+19
-1
@@ -1,12 +1,30 @@
|
|||||||
// Admin dashboard types
|
// 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;
|
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;
|
createdAt: string;
|
||||||
lastLogin: string | null;
|
lastLogin: string | null;
|
||||||
passwordChangedAt: string;
|
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 {
|
export interface AdminSessionPayload {
|
||||||
role: 'admin';
|
role: 'admin';
|
||||||
iat: number;
|
iat: number;
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
|
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
|
||||||
import { logger } from '@/lib/logger';
|
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 ALGORITHM = 'aes-256-gcm';
|
||||||
const IV_LENGTH = 12;
|
const IV_LENGTH = 12;
|
||||||
@@ -9,7 +9,7 @@ const TAG_LENGTH = 16;
|
|||||||
const MIN_SECRET_LENGTH = 32;
|
const MIN_SECRET_LENGTH = 32;
|
||||||
|
|
||||||
function getKey(): Buffer {
|
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) throw new Error('SESSION_SECRET not configured');
|
||||||
if (secret.length < MIN_SECRET_LENGTH) {
|
if (secret.length < MIN_SECRET_LENGTH) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,14 +3,14 @@ import { readFile, writeFile, unlink, mkdir, rename } from 'node:fs/promises';
|
|||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { logger } from '@/lib/logger';
|
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 ALGORITHM = 'aes-256-gcm';
|
||||||
const IV_LENGTH = 12;
|
const IV_LENGTH = 12;
|
||||||
const TAG_LENGTH = 16;
|
const TAG_LENGTH = 16;
|
||||||
|
|
||||||
function getKey(): Buffer {
|
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) throw new Error('SESSION_SECRET not configured');
|
||||||
return createHash('sha256').update(secret).digest();
|
return createHash('sha256').update(secret).digest();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { type NextRequest, NextResponse } from "next/server";
|
|||||||
import createIntlMiddleware from "next-intl/middleware";
|
import createIntlMiddleware from "next-intl/middleware";
|
||||||
import { routing } from "./i18n/routing";
|
import { routing } from "./i18n/routing";
|
||||||
import { getEnabledPluginFrameOrigins } from "./lib/admin/csp-frame-origins";
|
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);
|
const intlMiddleware = createIntlMiddleware(routing);
|
||||||
|
|
||||||
@@ -11,8 +13,59 @@ const intlMiddleware = createIntlMiddleware(routing);
|
|||||||
// requests for API routes, Next internals and static assets.
|
// requests for API routes, Next internals and static assets.
|
||||||
const PROXY_SKIP_PATTERN = /^\/(?:api|_next)(?:\/|$)|\.[^/]+$/;
|
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) {
|
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();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,9 +103,10 @@ export async function proxy(request: NextRequest) {
|
|||||||
`media-src 'self' blob:`,
|
`media-src 'self' blob:`,
|
||||||
].join("; ");
|
].join("; ");
|
||||||
|
|
||||||
// Skip intl middleware for /admin routes - they have their own layout
|
// Skip intl middleware for /admin and /setup routes - they have their
|
||||||
const pathname = request.nextUrl.pathname;
|
// own layout outside the [locale] tree.
|
||||||
const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/');
|
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
|
// When localePrefix is 'always', paths that already have a locale prefix
|
||||||
// (e.g. /en/settings) should not be re-processed by the intl middleware -
|
// (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;
|
let intlResponse: ReturnType<typeof intlMiddleware> | null = null;
|
||||||
if (!isAdminRoute && !hasLocalePrefix) {
|
if (!isAdminRoute && !isSetupRoute && !hasLocalePrefix) {
|
||||||
try {
|
try {
|
||||||
intlResponse = intlMiddleware(request);
|
intlResponse = intlMiddleware(request);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user