feat: hold each instance back until it has loaded from the shared store and add a readiness check #1066

This commit is contained in:
Linus Rath
2026-10-31 22:40:51 +01:00
parent df90492258
commit 1b2d81be27
14 changed files with 908 additions and 30 deletions
+4
View File
@@ -5,6 +5,7 @@ import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { CREDENTIAL_ID_RE } from '@/lib/plugin-secrets/credentials';
import { ProxyError, proxyRequest, type CaptureSpec } from '@/lib/plugin-secrets/proxy';
import { bootGateResponse } from '@/lib/shared-store/boot-gate';
const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
const FIELD_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
@@ -35,6 +36,9 @@ function parseCapture(raw: string | null): CaptureSpec | null | undefined {
* plugin itself (its iframe has an opaque origin and is refused here).
*/
export async function POST(request: NextRequest) {
// The proxy doesn't run for this route, so it applies the boot gate itself.
const notReady = bootGateResponse();
if (notReady) return notReady;
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { bootState, getStore, replicaId } from '@/lib/shared-store';
export const dynamic = 'force-dynamic';
/**
* Readiness probe (#1066), separate from the liveness probe /api/health.
*
* 200 when this instance can serve: always once booted with local files;
* with a shared store, once boot finished and the store answered a ping
* within a second. 503 otherwise, with the reason in `state`.
*
* Kubernetes: readinessProbe here, livenessProbe on /api/health. A store
* outage then takes pods out of the Service without restarting them.
*
* The ping result is reused for a second, so probes can't flood the store.
*/
const CACHE_MS = 1000;
let last: { at: number; ok: boolean } | null = null;
async function storeAnswers(): Promise<boolean> {
if (last && Date.now() - last.at < CACHE_MS) return last.ok;
let ok = true;
try {
await getStore().ping(1000);
} catch {
ok = false;
}
last = { at: Date.now(), ok };
return ok;
}
async function readiness(): Promise<{ status: number; body: Record<string, unknown> }> {
const store = getStore();
const state = bootState();
if (state !== 'ready') {
return { status: 503, body: { ready: false, store: store.mode, state } };
}
if (store.mode === 'local') return { status: 200, body: { ready: true, store: 'local' } };
if (!(await storeAnswers())) {
return { status: 503, body: { ready: false, store: store.mode, state: 'store-down', replica: replicaId() } };
}
return { status: 200, body: { ready: true, store: store.mode, replica: replicaId() } };
}
export async function GET() {
const { status, body } = await readiness();
return NextResponse.json(body, { status, headers: { 'Cache-Control': 'no-store' } });
}
export async function HEAD() {
const { status } = await readiness();
return new NextResponse(null, { status, headers: { 'Cache-Control': 'no-store' } });
}
+4
View File
@@ -6,6 +6,7 @@ import { forwardedClientHeaders, loginClientAddress } from '@/lib/auth/client-ad
import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
import { DisallowedUrlError } from '@/lib/security/url-guard';
import { UpstreamBodyTooLargeError, readBodyCapped, readTextCapped } from '@/lib/security/capped-body';
import { bootGateResponse } from '@/lib/shared-store/boot-gate';
/** A PROPFIND listing of a folder with many thousand entries still fits. */
const MAX_PROPFIND_RESPONSE_BYTES = 50 * 1024 * 1024;
@@ -59,6 +60,9 @@ function buildDavTargetUrl(baseUrl: string, username: string, rawPath: string, c
* Overwrite: WebDAV Overwrite header for MOVE/COPY
*/
export async function POST(request: NextRequest) {
// The proxy doesn't run for this route, so it applies the boot gate itself.
const notReady = bootGateResponse();
if (notReady) return notReady;
// CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's
// session cookie, which SameSite=Lax still sends from a same-site page.
const crossOrigin = rejectCrossOriginRequest(request);
@@ -3,6 +3,7 @@ import { logger } from '@/lib/logger';
import { downloadFileBlob, getWopiFileNode, updateFileNodeBlob, uploadFileBlob } from '@/lib/wopi/files';
import { wopiContext } from '@/lib/wopi/request';
import { readBodyCapped } from '@/lib/security/capped-body';
import { bootGateResponse } from '@/lib/shared-store/boot-gate';
/**
* Largest document PutFile takes. proxy.ts doesn't run for this route, so
@@ -39,6 +40,9 @@ export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ fileId: string }> },
) {
// The proxy doesn't run for this route, so it applies the boot gate itself.
const notReady = bootGateResponse();
if (notReady) return notReady;
// The editor fetches this server-to-server and sends no Sec-Fetch headers.
// A browser navigating to it, or loading it as a script or style, is not
// the editor.
@@ -89,6 +93,9 @@ export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ fileId: string }> },
) {
// The proxy doesn't run for this route, so it applies the boot gate itself.
const notReady = bootGateResponse();
if (notReady) return notReady;
try {
const { fileId: documentId } = await params;
const auth = await wopiContext(request, documentId);
+31 -11
View File
@@ -5,6 +5,9 @@ import { migrateLegacyAdminLayout, migratePolicyUnifiedMailbox } from "./lib/adm
import { detectSetupState } from "./lib/setup/state";
import { ensureSetupToken } from "./lib/setup/token";
import { ensureSessionSecret } from "./lib/auth/session-secret";
import { bootState, sharedStoreUrl } from "./lib/shared-store";
import { bootSharedMode } from "./lib/shared-store/boot";
import { importLocalData } from "./lib/shared-store/import";
const pkg = JSON.parse(
readFileSync(`${process.cwd()}/package.json`, "utf-8")
@@ -12,23 +15,40 @@ const pkg = JSON.parse(
const current: string = pkg.version ?? "0.0.0";
console.info(`Bulwark Webmail v${current}`);
// With REDIS_URL every replica shares one store (#1066). Without it,
// everything below runs exactly as it always has.
const shared = sharedStoreUrl() !== null;
async function loadAdmin(): Promise<void> {
await configManager.load();
await initAdminPassword();
}
async function bootShared(): Promise<void> {
await bootSharedMode({ version: current, importLocal: importLocalData, load: loadAdmin });
if (bootState() !== "ready") throw new Error(`shared store boot ended in state ${bootState()}`);
}
// Initialize admin config and password bootstrap. Migration runs first so
// existing v1 layouts are split before anything reads admin.json.
// existing v1 layouts are split before anything reads admin.json (with a
// shared store: before they are imported).
migrateLegacyAdminLayout()
.then(() => migratePolicyUnifiedMailbox())
.then(() => configManager.load())
.then(() => initAdminPassword())
.then(() => (shared ? bootShared() : loadAdmin()))
.then(async () => {
console.info("Admin dashboard initialized");
try {
if (await ensureSessionSecret()) {
console.info(
"Generated a session secret in the admin state dir. Keep that dir on a " +
"persistent volume, or set SESSION_SECRET, so restarts don't sign everyone out.",
);
// With a shared store, boot already made or loaded the secret.
if (!shared) {
try {
if (await ensureSessionSecret()) {
console.info(
"Generated a session secret in the admin state dir. Keep that dir on a " +
"persistent volume, or set SESSION_SECRET, so restarts don't sign everyone out.",
);
}
} catch (err) {
console.warn("Failed to generate a session secret:", err instanceof Error ? err.message : err);
}
} catch (err) {
console.warn("Failed to generate a session secret:", err instanceof Error ? err.message : err);
}
// If we're in bootstrap state (no JMAP_SERVER_URL env and no
// setupComplete in config.json), generate/refresh the setup token and
+74 -19
View File
@@ -3,26 +3,28 @@ import { writeFile } from 'node:fs/promises';
import { configManager } from '@/lib/admin/config-manager';
import { ensureStateDir, getStatePath } from '@/lib/admin/paths';
import { readFileEnv } from '@/lib/read-file-env';
import { getStore, isSharedMode } from '@/lib/shared-store';
/** Written on first start when no secret is configured (see ensureSessionSecret). */
const GENERATED_SECRET_FILE = '.session-secret';
export const GENERATED_SECRET_FILE = '.session-secret';
/** Where the generated secret lives in the shared store. */
export const SHARED_SECRET_KEY = 'session-secret';
/**
* 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)
* 4. The secret generated on first start, in the admin state dir
*
* 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.
* The generated secret as read from the shared store at boot. It never
* changes once created, so every module copy (proxy, routes) reads it from
* here without waiting on the store.
*/
export function getSessionSecret(): string {
const SHARED_CACHE = Symbol.for('bulwark.sessionSecret.shared');
type SecretGlobals = typeof globalThis & { [SHARED_CACHE]?: string };
function sharedSecret(): string {
return (globalThis as SecretGlobals)[SHARED_CACHE] ?? '';
}
/** A secret the operator set: env, file, or the setup wizard's config. */
function configuredSessionSecret(): string {
const fromEnv = process.env.SESSION_SECRET;
if (fromEnv) return fromEnv;
@@ -32,11 +34,29 @@ export function getSessionSecret(): string {
const fromAdmin = configManager.get<string>('sessionSecret', '');
if (fromAdmin) return fromAdmin;
const fromAdminFile = readFileEnv(
configManager.get<string>('sessionSecretFile', ''),
);
if (fromAdminFile) return fromAdminFile;
return readFileEnv(configManager.get<string>('sessionSecretFile', '')) || '';
}
/**
* 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)
* 4. The secret generated on first start: in the admin state dir, or with
* a shared store in the store, one for every replica (#1066)
*
* 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 configured = configuredSessionSecret();
if (configured) return configured;
if (isSharedMode()) return sharedSecret();
return readFileEnv(getStatePath(GENERATED_SECRET_FILE)) || "";
}
@@ -51,9 +71,14 @@ export function hasSessionSecret(): boolean {
* writable and a volume in docker-compose.yml; without a persistent volume a
* restart issues a new one and signs everyone out.
*
* With a shared store it lives in the store: the first replica creates it
* and every replica caches the stored value, so replicas starting at the
* same moment end up with one secret.
*
* Returns true when this call wrote a new secret.
*/
export async function ensureSessionSecret(): Promise<boolean> {
if (isSharedMode()) return ensureSharedSessionSecret();
if (hasSessionSecret()) return false;
await ensureStateDir();
try {
@@ -68,3 +93,33 @@ export async function ensureSessionSecret(): Promise<boolean> {
throw error;
}
}
/**
* Shared mode: create the stored secret if there is none (`value`, or a new
* random one), then load whatever the store holds into this process. Also
* called when a configured secret exists, so the stored one (used once the
* configured one is removed) is loaded either way.
*/
export async function ensureSharedSessionSecret(value?: string): Promise<boolean> {
const store = getStore();
const created = await store.kv.set(SHARED_SECRET_KEY, value ?? randomBytes(32).toString('base64'), { ifAbsent: true });
const stored = await store.kv.get(SHARED_SECRET_KEY);
if (!stored) throw new Error('The shared store lost the session secret right after writing it');
(globalThis as SecretGlobals)[SHARED_CACHE] = stored;
return created;
}
/** The generated secret of this install's state dir, if there is one (the import). */
export function localGeneratedSessionSecret(): string {
return readFileEnv(getStatePath(GENERATED_SECRET_FILE)) || '';
}
/** Whether the operator configured a secret (env, file or config). */
export function hasConfiguredSessionSecret(): boolean {
return configuredSessionSecret().length > 0;
}
/** Forget the cached shared secret (tests). */
export function resetSharedSessionSecretForTests(): void {
delete (globalThis as SecretGlobals)[SHARED_CACHE];
}
+210
View File
@@ -0,0 +1,210 @@
/**
* @vitest-environment node
*/
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/admin/config-manager', () => ({
configManager: { get: vi.fn((_key: string, fallback: unknown) => fallback) },
}));
import { bootState, getStore, setBootState, setStoreForTests } from '../index';
import { resetLocalStoreForTests } from '../local';
import {
INIT_EPOCH,
announceChange,
markEpochsSeen,
onSharedChange,
pollChanges,
resetChangesForTests,
} from '../changes';
import { bootSharedMode } from '../boot';
import {
ensureSharedSessionSecret,
getSessionSecret,
resetSharedSessionSecretForTests,
SHARED_SECRET_KEY,
} from '@/lib/auth/session-secret';
import { fakeSharedStore } from './fake-shared';
let stateDir: string;
beforeEach(() => {
stateDir = mkdtempSync(path.join(tmpdir(), 'bulwark-boot-'));
vi.stubEnv('ADMIN_STATE_DIR', stateDir);
vi.stubEnv('SESSION_SECRET', '');
vi.stubEnv('SESSION_SECRET_FILE', '');
resetLocalStoreForTests();
resetChangesForTests();
resetSharedSessionSecretForTests();
});
afterEach(() => {
setStoreForTests(null);
resetChangesForTests();
vi.unstubAllEnvs();
rmSync(stateDir, { recursive: true, force: true });
});
const noImport = async () => ({ imported: {} });
describe('change notices', () => {
it('runs this process\'s listeners at once in local mode', async () => {
const seen: string[] = [];
onSharedChange('config', (origin) => void seen.push(origin));
await announceChange('config');
expect(seen).toEqual(['local']);
});
it('runs listeners for another replica\'s change on the next poll, once', async () => {
const store = fakeSharedStore();
setStoreForTests(store);
await store.epochs.bump(INIT_EPOCH);
await markEpochsSeen();
const seen: string[] = [];
onSharedChange('policy', (origin) => void seen.push(origin));
await store.epochs.bump('policy'); // another replica saved
await pollChanges();
await pollChanges();
expect(seen).toEqual(['remote']);
});
it('does not reload its own change, but does when another landed in between', async () => {
const store = fakeSharedStore();
setStoreForTests(store);
await store.epochs.bump(INIT_EPOCH);
await markEpochsSeen();
const seen: string[] = [];
onSharedChange('config', (origin) => void seen.push(origin));
await announceChange('config');
await pollChanges();
expect(seen).toEqual(['local']);
await store.epochs.bump('config'); // other replica
await announceChange('config'); // ours right after
await pollChanges();
expect(seen).toEqual(['local', 'local', 'remote']);
});
it('retries a listener that failed on the next poll', async () => {
const store = fakeSharedStore();
setStoreForTests(store);
await store.epochs.bump(INIT_EPOCH);
await markEpochsSeen();
let calls = 0;
onSharedChange('plugins', () => {
calls++;
if (calls === 1) throw new Error('store hiccup');
});
await store.epochs.bump('plugins');
await pollChanges();
await pollChanges();
await pollChanges();
expect(calls).toBe(2);
});
it('goes to store-lost when the store forgets the deployment, and back when it returns', async () => {
const store = fakeSharedStore();
setStoreForTests(store);
setBootState('ready');
await pollChanges(); // no _init epoch: an emptied store
expect(bootState()).toBe('store-lost');
const reloaded: string[] = [];
onSharedChange('config', (origin) => void reloaded.push(origin));
await store.epochs.bump(INIT_EPOCH); // restored
await pollChanges();
expect(bootState()).toBe('ready');
expect(reloaded).toEqual(['remote']);
});
});
describe('session secret with a shared store', () => {
it('gives every replica the same generated secret', async () => {
setStoreForTests(fakeSharedStore());
const created = await Promise.all([ensureSharedSessionSecret(), ensureSharedSessionSecret(), ensureSharedSessionSecret()]);
expect(created.filter(Boolean)).toHaveLength(1);
const stored = await getStore().kv.get(SHARED_SECRET_KEY);
expect(stored).toMatch(/^[A-Za-z0-9+/]{43}=$/);
expect(getSessionSecret()).toBe(stored);
});
it('ignores the state dir file in shared mode and lets SESSION_SECRET win', async () => {
writeFileSync(path.join(stateDir, '.session-secret'), 'local-file-secret-local-file-secret-xx');
setStoreForTests(fakeSharedStore());
await ensureSharedSessionSecret();
expect(getSessionSecret()).not.toBe('local-file-secret-local-file-secret-xx');
vi.stubEnv('SESSION_SECRET', 'from-env-from-env-from-env-from-env-xx');
expect(getSessionSecret()).toBe('from-env-from-env-from-env-from-env-xx');
});
});
describe('bootSharedMode', () => {
it('initialises an empty store: secret, marker, then loads and becomes ready', async () => {
const store = fakeSharedStore();
setStoreForTests(store);
const order: string[] = [];
await bootSharedMode({
version: '9.9.9',
importLocal: async () => { order.push('import'); return { imported: { settings: 2 } }; },
load: async () => { order.push('load'); },
});
expect(order).toEqual(['import', 'load']);
expect(bootState()).toBe('ready');
expect((await store.epochs.all())[INIT_EPOCH]).toBe(1);
expect(JSON.parse((await store.kv.get('meta:initialized'))!)).toMatchObject({ imported: { settings: 2 } });
expect(getSessionSecret()).toBe(await store.kv.get(SHARED_SECRET_KEY));
expect((await store.replicas.recent(60_000)).map((r) => r.version)).toEqual(['9.9.9']);
});
it('imports this install\'s generated secret before creating one', async () => {
writeFileSync(path.join(stateDir, '.session-secret'), 'old-install-secret-old-install-secret');
const store = fakeSharedStore();
setStoreForTests(store);
await bootSharedMode({ version: '1', importLocal: noImport, load: async () => {} });
expect(await store.kv.get(SHARED_SECRET_KEY)).toBe('old-install-secret-old-install-secret');
expect(getSessionSecret()).toBe('old-install-secret-old-install-secret');
});
it('joins an initialised store without importing again', async () => {
const store = fakeSharedStore();
setStoreForTests(store);
await bootSharedMode({ version: '1', importLocal: noImport, load: async () => {} });
const importLocal = vi.fn(noImport);
resetSharedSessionSecretForTests();
await bootSharedMode({ version: '1', importLocal, load: async () => {} });
expect(importLocal).not.toHaveBeenCalled();
expect(bootState()).toBe('ready');
expect((await store.epochs.all())[INIT_EPOCH]).toBe(1);
});
it('refuses to serve when its own local data was encrypted with another secret', async () => {
const store = fakeSharedStore();
setStoreForTests(store);
await bootSharedMode({ version: '1', importLocal: noImport, load: async () => {} });
mkdirSync(stateDir, { recursive: true });
writeFileSync(path.join(stateDir, '.session-secret'), 'a-different-install-secret-a-different');
const load = vi.fn(async () => {});
await bootSharedMode({ version: '1', importLocal: noImport, load });
expect(bootState()).toBe('blocked');
expect(load).not.toHaveBeenCalled();
});
it('waits for a store that is down at start', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval'] });
try {
const store = fakeSharedStore();
store.down = true;
setStoreForTests(store);
const done = bootSharedMode({ version: '1', importLocal: noImport, load: async () => {} });
await vi.advanceTimersByTimeAsync(5000);
expect(bootState()).toBe('starting');
store.down = false;
await vi.advanceTimersByTimeAsync(5000);
await done;
expect(bootState()).toBe('ready');
} finally {
vi.useRealTimers();
}
});
});
+27
View File
@@ -0,0 +1,27 @@
import { createLocalStore } from '../local';
import type { SharedStore } from '../types';
/**
* The local store under the shared store's name: everything that branches
* on isSharedMode() takes its shared path, in one process. The contract
* suite proves both implementations behave alike, so this stands in for a
* Redis in unit tests. `down` makes every call fail as an outage would.
*/
export function fakeSharedStore(): SharedStore & { down: boolean } {
const local = createLocalStore();
const fake = {
...local,
mode: 'redis' as const,
down: false,
async ping() {
if (fake.down) {
const { StoreUnavailableError } = await import('../types');
throw new StoreUnavailableError('down (test)');
}
},
status() {
return { mode: 'redis' as const, location: 'redis://fake:6379/0', connected: !fake.down };
},
};
return fake;
}
@@ -0,0 +1,92 @@
/**
* @vitest-environment node
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: vi.fn(async () => {}),
get: vi.fn((_key: string, fallback: unknown) => fallback),
getPolicy: vi.fn(() => ({ features: { pluginsEnabled: false } })),
},
}));
vi.mock('@/lib/setup/state', () => ({ detectSetupState: vi.fn(() => 'configured') }));
vi.mock('@/lib/admin/csp-frame-origins', () => ({ getEnabledPluginFrameOrigins: vi.fn(async () => []) }));
vi.mock('next-intl/middleware', async () => {
const { NextResponse } = await import('next/server');
return { default: () => () => NextResponse.next() };
});
import { proxy } from '@/proxy';
import { GET as ready } from '@/app/api/ready/route';
import { configManager } from '@/lib/admin/config-manager';
import { setBootState, setStoreForTests } from '../index';
import { fakeSharedStore } from './fake-shared';
afterEach(() => {
setStoreForTests(null);
vi.clearAllMocks();
});
function request(path: string): NextRequest {
return new NextRequest('http://localhost:3000' + path, {
headers: { 'sec-fetch-dest': 'document', 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'none' },
});
}
describe('/api/ready', () => {
it('is ready with local files, as a single instance always was', async () => {
const res = await ready();
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ ready: true, store: 'local' });
});
it('is not ready while a shared-store replica is starting', async () => {
setStoreForTests(fakeSharedStore());
const res = await ready();
expect(res.status).toBe(503);
expect(await res.json()).toMatchObject({ ready: false, state: 'starting' });
});
it('follows the store once booted', async () => {
const store = fakeSharedStore();
setStoreForTests(store);
setBootState('ready');
expect((await ready()).status).toBe(200);
store.down = true;
await new Promise((r) => setTimeout(r, 1100)); // past the 1 s ping cache
const down = await ready();
expect(down.status).toBe(503);
expect(await down.json()).toMatchObject({ state: 'store-down' });
});
});
describe('proxy boot gate', () => {
it('serves nothing but the probes while a shared-store replica is starting', async () => {
setStoreForTests(fakeSharedStore());
const page = await proxy(request('/en/mail'));
expect(page.status).toBe(503);
expect(page.headers.get('retry-after')).toBe('5');
const api = await proxy(request('/api/settings'));
expect(api.status).toBe(503);
expect(await api.json()).toMatchObject({ error: 'not_ready', state: 'starting' });
// The probes pass through to their routes, without touching config.
for (const probe of ['/api/health', '/api/ready']) {
const res = await proxy(request(probe));
expect(res.status).toBe(200);
expect(res.headers.get('x-middleware-next')).toBe('1');
}
expect(configManager.ensureLoaded).not.toHaveBeenCalled();
});
it('lets everything through once booted, and always in local mode', async () => {
const page = await proxy(request('/en/mail'));
expect(page.status).not.toBe(503);
setStoreForTests(fakeSharedStore());
setBootState('ready');
expect((await proxy(request('/en/mail'))).status).not.toBe(503);
setBootState('store-lost');
expect((await proxy(request('/en/mail'))).status).toBe(503);
});
});
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import { bootState } from './index';
const MESSAGES = {
starting: 'Bulwark is starting and connecting to its shared store.',
'store-lost': 'Bulwark lost its shared store data and is waiting for it to be restored.',
blocked: 'This Bulwark instance has local data that conflicts with the shared store. See the server log.',
} as const;
/**
* 503 for every request while this replica can't serve (shared mode only:
* local mode is always ready). The proxy calls it for everything but the
* health and readiness probes; the three upload routes the proxy skips call
* it themselves.
*/
export function bootGateResponse(pathname?: string): NextResponse | null {
const state = bootState();
if (state === 'ready') return null;
const message = MESSAGES[state];
const headers = { 'Retry-After': '5', 'Cache-Control': 'no-store' };
if (!pathname || pathname.startsWith('/api/')) {
return NextResponse.json({ error: 'not_ready', state, message }, { status: 503, headers });
}
return new NextResponse(message, { status: 503, headers: { ...headers, 'Content-Type': 'text/plain; charset=utf-8' } });
}
+170
View File
@@ -0,0 +1,170 @@
import { logger } from '@/lib/logger';
import {
ensureSharedSessionSecret,
hasConfiguredSessionSecret,
localGeneratedSessionSecret,
SHARED_SECRET_KEY,
} from '@/lib/auth/session-secret';
import { INIT_EPOCH, markEpochsSeen, startChangePoller } from './changes';
import { getStore, replicaId, setBootState } from './index';
/**
* Boot with a shared store (#1066). Order:
*
* 1. Wait until the store answers (the replica reports "starting" meanwhile;
* the proxy answers 503 to everything but the probes).
* 2. Under the `boot` lease, so replicas starting together take turns:
* - Store not initialised yet: import this replica's local files (an
* existing single-instance install moving over), create the session
* secret if none was imported, then mark the store initialised. The
* marker is written last, so an import that dies halfway is redone.
* - Already initialised: check that this replica's local data doesn't
* conflict with it (a generated secret of its own would mean data
* encrypted with another key).
* 3. Load the secret, take the epochs as the baseline, run `load` (config
* and the rest), start the change poller and the replica heartbeat.
*/
export interface ImportSummary {
/** What was written per store, e.g. { settings: 3, config: 1 }. */
imported: Record<string, number>;
}
export interface SharedBootOptions {
version: string;
/** Copy this replica's local files into an uninitialised store. */
importLocal: () => Promise<ImportSummary>;
/** Load everything this process caches from the store (config, …). */
load: () => Promise<void>;
}
const BOOT_LEASE = 'boot';
const BOOT_LEASE_TTL_MS = 60_000;
const HEARTBEAT_MS = 15_000;
const META_KEY = 'meta:initialized';
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForStore(): Promise<void> {
const store = getStore();
let warned = false;
for (;;) {
try {
await store.ping(2000);
return;
} catch (error) {
if (!warned) {
warned = true;
logger.warn('Waiting for the shared store', {
location: store.status().location,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
await sleep(2000);
}
}
}
/** Hold the boot lease while `task` runs, renewing it; wait for it first. */
async function withBootLease<T>(task: () => Promise<T>): Promise<T> {
const store = getStore();
const holder = replicaId();
while (!(await store.leases.acquire(BOOT_LEASE, holder, BOOT_LEASE_TTL_MS))) await sleep(1000);
const renew = setInterval(() => {
void store.leases.acquire(BOOT_LEASE, holder, BOOT_LEASE_TTL_MS).catch(() => {});
}, BOOT_LEASE_TTL_MS / 3);
renew.unref();
try {
return await task();
} finally {
clearInterval(renew);
await store.leases.release(BOOT_LEASE, holder).catch(() => {});
}
}
async function initialise(options: SharedBootOptions): Promise<'initialised' | 'joined' | 'blocked'> {
const store = getStore();
return withBootLease(async () => {
const epochs = await store.epochs.all();
if (!epochs[INIT_EPOCH]) {
// The local generated secret first: settings and plugin secrets
// imported with it are encrypted with it.
const localSecret = localGeneratedSessionSecret();
if (localSecret) await store.kv.set(SHARED_SECRET_KEY, localSecret, { ifAbsent: true });
const summary = await options.importLocal();
await ensureSharedSessionSecret();
await store.kv.set(META_KEY, JSON.stringify({ at: new Date().toISOString(), by: replicaId(), ...summary }));
await store.epochs.bump(INIT_EPOCH);
const total = Object.values(summary.imported).reduce((a, b) => a + b, 0);
logger.info(
total > 0 ? 'Shared store initialised from this instance\'s local data' : 'Shared store initialised (no local data to import)',
summary.imported,
);
return 'initialised';
}
// Joining: a generated secret in this replica's state dir that isn't the
// store's means its local data belongs to another install. Serving would
// be fine, but the admin most likely meant to import that data.
const localSecret = localGeneratedSessionSecret();
const storedSecret = await store.kv.get(SHARED_SECRET_KEY);
if (localSecret && storedSecret && localSecret !== storedSecret && !hasConfiguredSessionSecret()) {
logger.error(
'This instance has local data (a generated session secret in its state dir) from a single-instance ' +
'install, but the shared store was already initialised without it. Its settings and plugin ' +
'credentials could not be read. Either remove the local data dirs from this instance, or empty the ' +
'shared store and start this instance first so it imports them. See the docs, "Running several instances".',
);
return 'blocked';
}
return 'joined';
});
}
function startHeartbeat(version: string): void {
const store = getStore();
const info = { id: replicaId(), version, startedAt: Date.now() };
const beat = () => void store.replicas.beat(info).catch(() => {});
beat();
setInterval(beat, HEARTBEAT_MS).unref();
}
export async function bootSharedMode(options: SharedBootOptions): Promise<void> {
setBootState('starting');
const store = getStore();
logger.info('Using a shared store', { location: store.status().location, replica: replicaId() });
await waitForStore();
// A store that drops mid-boot is waited for again at each step.
for (;;) {
try {
const result = await initialise(options);
if (result === 'blocked') {
setBootState('blocked');
return;
}
await ensureSharedSessionSecret();
if (!hasConfiguredSessionSecret()) {
logger.warn(
'No SESSION_SECRET set; using the one in the shared store. Set SESSION_SECRET on every replica, ' +
'so a lost store does not sign everyone out and make stored settings unreadable.',
);
}
// Baseline before loading, so a change during the load is seen.
await markEpochsSeen();
await options.load();
break;
} catch (error) {
logger.warn('Shared store boot step failed; retrying', {
error: error instanceof Error ? error.message : 'Unknown error',
});
await sleep(2000);
await waitForStore();
}
}
startChangePoller();
startHeartbeat(options.version);
setBootState('ready');
logger.info('Shared store ready', { replica: replicaId() });
}
+182
View File
@@ -0,0 +1,182 @@
import { logger } from '@/lib/logger';
import { getStore, isSharedMode, setBootState } from './index';
/**
* How admin changes reach every replica (#1066).
*
* Every write to shared state calls announceChange(area): that bumps the
* area's epoch in the store and runs this process's listeners at once (they
* may live in another module copy: proxy, instrumentation, routes). Every
* replica reads all epochs once a second and runs the listeners of each
* area whose epoch moved. A replica therefore sees a change within about a
* second, and one that couldn't reach the store for a while catches up on
* its first successful read.
*
* The epoch is noted before the listeners reload, so a change that lands
* while they read is picked up by the next poll.
*
* Local mode: no poller; announceChange only runs this process's listeners.
*/
export type ChangeOrigin = 'local' | 'remote';
export type ChangeListener = (origin: ChangeOrigin) => void | Promise<void>;
/** Set once the store holds a deployment; its absence later means the store lost its data. */
export const INIT_EPOCH = '_init';
export const POLL_INTERVAL_MS = 1000;
interface ChangeGlobals {
listeners: Map<string, Set<ChangeListener>>;
seen: Map<string, number>;
poller?: NodeJS.Timeout;
polling?: Promise<void>;
lastPollOk: number;
storeLost: boolean;
}
const KEY = Symbol.for('bulwark.sharedStore.changes');
function globals(): ChangeGlobals {
const g = globalThis as typeof globalThis & { [KEY]?: ChangeGlobals };
return (g[KEY] ??= { listeners: new Map(), seen: new Map(), lastPollOk: 0, storeLost: false });
}
/**
* Run `fn` when `area` changes: at once for a write in this process
* (origin 'local'), and after another replica's write (origin 'remote',
* shared mode only). Returns an unsubscribe function.
*/
export function onSharedChange(area: string, fn: ChangeListener): () => void {
const listeners = globals().listeners;
let set = listeners.get(area);
if (!set) listeners.set(area, (set = new Set()));
set.add(fn);
return () => set!.delete(fn);
}
async function runListeners(area: string, origin: ChangeOrigin): Promise<boolean> {
let ok = true;
for (const fn of [...(globals().listeners.get(area) ?? [])]) {
try {
await fn(origin);
} catch (error) {
ok = false;
logger.warn('Could not apply a change from the shared store', {
area,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
return ok;
}
/**
* Tell every replica that `area` changed. Call it after the write went
* through. In shared mode a failure to bump the epoch is logged, not thrown:
* the write itself succeeded, and other replicas then see it on their next
* reload of that area (or after their next restart) instead.
*/
export async function announceChange(area: string): Promise<void> {
const g = globals();
if (isSharedMode()) {
try {
const epoch = await getStore().epochs.bump(area);
// Only skip our own change when nothing else landed in between;
// otherwise the poller reloads to pick up the other replica's.
if ((g.seen.get(area) ?? 0) === epoch - 1) g.seen.set(area, epoch);
} catch (error) {
logger.warn('Could not announce a change to the other replicas', {
area,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
await runListeners(area, 'local');
}
/** One poll: read the epochs, run the listeners of every area that moved. */
export async function pollChanges(): Promise<void> {
const g = globals();
if (g.polling) return g.polling;
g.polling = (async () => {
const epochs = await getStore().epochs.all();
if (!epochs[INIT_EPOCH]) {
if (!g.storeLost) {
g.storeLost = true;
setBootState('store-lost');
logger.error(
'The shared store no longer holds this deployment (restarted without persistence, flushed or evicted). ' +
'Keeping the last loaded config; nothing is regenerated. Restore the store from a backup, ' +
'or restart every replica to start it over.',
);
}
return;
}
g.lastPollOk = Date.now();
if (g.storeLost) {
// Restored from a backup: reload everything from it.
g.storeLost = false;
g.seen.clear();
setBootState('ready');
logger.warn('The shared store holds this deployment again; reloading from it');
for (const area of g.listeners.keys()) if (!(area in epochs)) await runListeners(area, 'remote');
}
for (const [area, epoch] of Object.entries(epochs)) {
if (area === INIT_EPOCH) continue;
const before = g.seen.get(area) ?? 0;
if (epoch === before) continue;
g.seen.set(area, epoch);
if (!(await runListeners(area, 'remote'))) {
// Try again on the next poll.
if (g.seen.get(area) === epoch) g.seen.set(area, before);
}
}
})()
.catch(() => {
// Store unreachable: readiness reports it; the next poll catches up.
})
.finally(() => {
g.polling = undefined;
});
return g.polling;
}
/**
* Take the current epochs as the baseline, so the first poll doesn't reload
* what boot just loaded. Call before loading the data the listeners reload.
*/
export async function markEpochsSeen(): Promise<void> {
const epochs = await getStore().epochs.all();
const g = globals();
for (const [area, epoch] of Object.entries(epochs)) g.seen.set(area, epoch);
}
export function startChangePoller(intervalMs = POLL_INTERVAL_MS): void {
const g = globals();
if (g.poller || !isSharedMode()) return;
g.poller = setInterval(() => void pollChanges(), intervalMs);
g.poller.unref();
}
export function stopChangePoller(): void {
const g = globals();
if (g.poller) clearInterval(g.poller);
g.poller = undefined;
}
/** When the epochs were last read successfully (ms), 0 if never. */
export function lastSuccessfulPoll(): number {
return globals().lastPollOk;
}
export function storeLostItsData(): boolean {
return globals().storeLost;
}
/** Forget listeners and epochs (tests). */
export function resetChangesForTests(): void {
stopChangePoller();
const g = globalThis as typeof globalThis & { [KEY]?: ChangeGlobals };
delete g[KEY];
}
+13
View File
@@ -0,0 +1,13 @@
import type { ImportSummary } from './boot';
/**
* Copy an existing single-instance install's local files into an
* uninitialised shared store (#1066). Every store adds its own step here;
* each step writes only what the store doesn't hold yet (put with
* ifVersion: null), so running it again changes nothing, and it never
* changes or deletes a local file.
*/
export async function importLocalData(): Promise<ImportSummary> {
const imported: Record<string, number> = {};
return { imported };
}
+14
View File
@@ -15,6 +15,7 @@ import { listReachableHosts, noThirdPartyCspSources } from "./lib/admin/reachabl
import { getWopiEditorOrigins } from "./lib/wopi/discovery";
import { detectSetupState } from "./lib/setup/state";
import { isServedOverHttps } from "./lib/security/request-protocol";
import { bootGateResponse } from "./lib/shared-store/boot-gate";
const intlMiddleware = createIntlMiddleware(routing);
@@ -187,6 +188,19 @@ function isSetupPath(pathname: string): boolean {
}
export async function proxy(request: NextRequest) {
// The probes answer without config or store, so a replica that is still
// starting (or lost its shared store) can say so (#1066).
const probePath = routePathOf(request.nextUrl.pathname);
if (probePath === "/api/health" || probePath === "/api/ready") {
const probe = NextResponse.next();
probe.headers.set("X-Content-Type-Options", "nosniff");
probe.headers.set("X-Frame-Options", "DENY");
return probe;
}
// With a shared store, nothing else is served until boot finished.
const notReady = bootGateResponse(probePath);
if (notReady) return notReady;
// 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();