Merge branch 'main' into feat/observability

This commit is contained in:
Linus Rath
2026-11-01 04:21:12 +01:00
20 changed files with 809 additions and 9 deletions
+103
View File
@@ -0,0 +1,103 @@
name: "CI: Kubernetes manifests"
# Builds the image from this commit, deploys deploy/kubernetes to a kind
# cluster (three replicas and one Valkey) and runs
# deploy/kubernetes/test/smoke.sh against it: every replica ready on the
# shared store, one admin session valid everywhere, a config change reaching
# every replica, no failed requests while a pod is deleted and during a
# rolling restart. The config-in-git overlay runs next to it in its own
# namespace and must refuse config saves.
#
# It runs when the manifests change, weekly on main (an app change can break
# several replicas without touching the manifests), and on demand:
# gh workflow run kubernetes.yml
on:
pull_request:
paths:
- deploy/kubernetes/**
- .github/workflows/kubernetes.yml
push:
branches: [main]
paths:
- deploy/kubernetes/**
- .github/workflows/kubernetes.yml
schedule:
- cron: "41 3 * * 1"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: kubernetes-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
kind:
name: kind
runs-on: ubuntu-latest
timeout-minutes: 45
env:
KIND_VERSION: v0.29.0
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Build the image
run: docker build --build-arg "GIT_COMMIT=${GITHUB_SHA}" -t bulwark-local:ci .
- name: Create the cluster
run: |
# The runner's own kind in /usr/local/bin belongs to root; use a pinned one.
mkdir -p "$RUNNER_TEMP/bin"
curl -fsSLo "$RUNNER_TEMP/bin/kind" "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-amd64"
chmod +x "$RUNNER_TEMP/bin/kind"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
export PATH="$RUNNER_TEMP/bin:$PATH"
kind create cluster --name ci --wait 180s
kind load docker-image bulwark-local:ci --name ci
- name: Deploy the base and the config-in-git overlay
run: |
umask 077
admin="$(openssl rand -hex 16)"
echo "::add-mask::$admin"
{
echo "SESSION_SECRET=$(openssl rand -base64 32)"
echo "ADMIN_PASSWORD=$admin"
echo "VALKEY_PASSWORD=$(openssl rand -hex 24)"
} > deploy/kubernetes/base/secrets.env
echo "ADMIN_PASSWORD=$admin" >> "$GITHUB_ENV"
for target in base:bulwark overlays/config-in-git:bulwark-ro; do
src="${target%%:*}"; ns="${target##*:}"
mkdir -p "ci/$ns"
cat > "ci/$ns/kustomization.yaml" <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: $ns
resources:
- ../../deploy/kubernetes/$src
images:
- name: ghcr.io/bulwarkmail/webmail
newName: bulwark-local
newTag: ci
EOF
kubectl create namespace "$ns" --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -k "ci/$ns"
done
- name: Smoke test (base)
run: bash deploy/kubernetes/test/smoke.sh bulwark
- name: Smoke test (config in git)
run: READ_ONLY_CONFIG=1 bash deploy/kubernetes/test/smoke.sh bulwark-ro
- name: Show the cluster
if: failure()
run: |
kubectl get pods -A -o wide
for ns in bulwark bulwark-ro; do
kubectl -n "$ns" describe pods | tail -n 80
kubectl -n "$ns" logs -l app.kubernetes.io/name=bulwark --tail=80 --prefix || true
done
+3
View File
@@ -57,3 +57,6 @@ next-env.d.ts
# Sibling repos
/repos/
# Kubernetes reference manifests: the secrets users fill in
deploy/kubernetes/base/secrets.env
+13
View File
@@ -0,0 +1,13 @@
# Settings for every Bulwark replica (no secrets here; those go in secrets.env).
# Any variable from https://bulwarkmail.org/docs/getting-started/configuration/environment-reference
# can be added.
# The JMAP server users sign in to. Both the pods and users' browsers must reach it.
JMAP_SERVER_URL=https://mail.example.com
# Proxies in front of the pods: 1 for an ingress controller alone, 2 if a cloud
# load balancer sits in front of it. Sign-in limits key on the address this picks.
TRUSTED_PROXY_DEPTH=1
SETTINGS_SYNC_ENABLED=true
LOG_LEVEL=info
+144
View File
@@ -0,0 +1,144 @@
# Bulwark itself: any number of replicas, no volumes. Everything they share
# (admin config, plugins, branding, synced settings, sign-in limits, sessions)
# lives in the Valkey store; see https://bulwarkmail.org/docs/deployment/multi-instance
apiVersion: v1
kind: Service
metadata:
name: bulwark
labels:
app.kubernetes.io/name: bulwark
spec:
selector:
app.kubernetes.io/name: bulwark
ports:
- name: http
port: 80
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: bulwark
labels:
app.kubernetes.io/name: bulwark
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app.kubernetes.io/name: bulwark
template:
metadata:
labels:
app.kubernetes.io/name: bulwark
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
seccompProfile:
type: RuntimeDefault
# Spread the replicas over nodes where there are several.
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: bulwark
terminationGracePeriodSeconds: 30
containers:
- name: bulwark
image: ghcr.io/bulwarkmail/webmail
ports:
- name: http
containerPort: 3000
envFrom:
- configMapRef:
name: bulwark-env
env:
- name: SESSION_SECRET
valueFrom:
secretKeyRef:
name: bulwark-secrets
key: SESSION_SECRET
- name: ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: bulwark-secrets
key: ADMIN_PASSWORD
- name: VALKEY_PASSWORD
valueFrom:
secretKeyRef:
name: bulwark-secrets
key: VALKEY_PASSWORD
# Kubernetes fills in $(VALKEY_PASSWORD) from the variable above.
- name: REDIS_URL
value: redis://:$(VALKEY_PASSWORD)@valkey:6379/0
# /api/health only says the process runs; /api/ready also says it
# finished starting and reaches the store. A store outage takes the
# pods out of the Service without restarting them.
startupProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 2
failureThreshold: 60
readinessProbe:
httpGet:
path: /api/ready
port: http
periodSeconds: 5
failureThreshold: 2
livenessProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 10
failureThreshold: 3
lifecycle:
# Let the Service drop the pod before it stops taking requests.
preStop:
exec:
command: ["sleep", "5"]
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 768Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
# Scratch space only; nothing here needs to survive the pod.
- name: data
mountPath: /app/data
- name: next-cache
mountPath: /app/.next/cache
- name: tmp
mountPath: /tmp
volumes:
- name: data
emptyDir: {}
- name: next-cache
emptyDir: {}
- name: tmp
emptyDir: {}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: bulwark
spec:
maxUnavailable: 1
selector:
matchLabels:
app.kubernetes.io/name: bulwark
+32
View File
@@ -0,0 +1,32 @@
# The base the top-level kustomization.yaml and the overlays use. Change the
# namespace, the image tag (pin a release, never latest in production) and
# the replica count here or, better, in an overlay. secrets.env must exist
# next to this file (copy secrets.env.example).
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: bulwark
resources:
- namespace.yaml
- valkey.yaml
- bulwark.yaml
configMapGenerator:
- name: bulwark-env
envs:
- bulwark.env
secretGenerator:
- name: bulwark-secrets
envs:
- secrets.env
images:
- name: ghcr.io/bulwarkmail/webmail
newTag: latest
labels:
- pairs:
app.kubernetes.io/part-of: bulwark
includeSelectors: false
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: bulwark
@@ -0,0 +1,13 @@
# Copy to secrets.env (kept out of git) and fill in. Generate each with:
# openssl rand -base64 32
# Or delete the secretGenerator in kustomization.yaml and create the Secret
# `bulwark-secrets` with these three keys some other way (External Secrets,
# Sealed Secrets, your CI).
# Encrypts sessions, synced settings and plugin credentials. The same on every
# replica; changing it signs everyone out.
SESSION_SECRET=
# First admin password; hashed into the store on the first start.
ADMIN_PASSWORD=
# Password of the Valkey store.
VALKEY_PASSWORD=
+95
View File
@@ -0,0 +1,95 @@
# The shared store every Bulwark replica uses. One Valkey with persistence
# (AOF) and noeviction: an evicting policy would silently drop settings and
# admin config under memory pressure. A managed Redis or Valkey works as well;
# then delete this file from kustomization.yaml and point REDIS_URL at it.
apiVersion: v1
kind: Service
metadata:
name: valkey
labels:
app.kubernetes.io/name: valkey
spec:
clusterIP: None
selector:
app.kubernetes.io/name: valkey
ports:
- name: redis
port: 6379
targetPort: redis
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: valkey
labels:
app.kubernetes.io/name: valkey
spec:
serviceName: valkey
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: valkey
template:
metadata:
labels:
app.kubernetes.io/name: valkey
spec:
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
fsGroup: 999
seccompProfile:
type: RuntimeDefault
containers:
- name: valkey
image: valkey/valkey:8-alpine
command: ["sh", "-c"]
args:
- >-
exec valkey-server
--requirepass "$VALKEY_PASSWORD"
--appendonly yes
--maxmemory 512mb
--maxmemory-policy noeviction
--dir /data
env:
- name: VALKEY_PASSWORD
valueFrom:
secretKeyRef:
name: bulwark-secrets
key: VALKEY_PASSWORD
ports:
- name: redis
containerPort: 6379
readinessProbe:
exec:
command: ["sh", "-c", "REDISCLI_AUTH=\"$VALKEY_PASSWORD\" valkey-cli --no-auth-warning ping | grep -q PONG"]
periodSeconds: 5
livenessProbe:
exec:
command: ["sh", "-c", "REDISCLI_AUTH=\"$VALKEY_PASSWORD\" valkey-cli --no-auth-warning ping | grep -q PONG"]
periodSeconds: 10
failureThreshold: 6
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 640Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
+28
View File
@@ -0,0 +1,28 @@
# Not applied by kustomization.yaml: adapt and apply it yourself, or use a
# Gateway API HTTPRoute instead. Plain round robin is fine; Bulwark needs no
# sticky sessions. Set TRUSTED_PROXY_DEPTH in bulwark.env to the number of
# proxies in front of the pods (1 for the ingress controller alone).
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bulwark
namespace: bulwark
annotations:
# Attachments and plugin packages can be large.
nginx.ingress.kubernetes.io/proxy-body-size: 100m
spec:
ingressClassName: nginx
tls:
- hosts: [webmail.example.com]
secretName: bulwark-tls
rules:
- host: webmail.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bulwark
port:
name: http
+14
View File
@@ -0,0 +1,14 @@
# Bulwark Webmail on Kubernetes: three replicas sharing one Valkey store.
# https://bulwarkmail.org/docs/deployment/kubernetes
#
# cp base/secrets.env.example base/secrets.env # fill in the three secrets
# $EDITOR base/bulwark.env # at least JMAP_SERVER_URL
# kubectl apply -k . # or: -k overlays/config-in-git
#
# For your own changes (namespace, image tag, replicas, resources) add an
# overlay next to overlays/config-in-git that uses ../../base.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- base
@@ -0,0 +1,20 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: bulwark
spec:
template:
spec:
containers:
- name: bulwark
env:
- name: ADMIN_CONFIG_READONLY
value: "true"
volumeMounts:
- name: admin-config
mountPath: /app/data/admin
readOnly: true
volumes:
- name: admin-config
configMap:
name: bulwark-admin-config
@@ -0,0 +1,4 @@
{
"appName": "Webmail",
"setupComplete": true
}
@@ -0,0 +1,23 @@
# Admin config and user policy from files in git instead of the admin console.
# Every replica reads config.json and policy.json from the ConfigMap and picks
# up a change within a few seconds; the admin console shows them but can't
# save them. Sessions, settings and the audit log still live in the store.
#
# kubectl apply -k overlays/config-in-git
#
# To start from your current policy, use "Export policy.json" on the admin
# console's Domains and groups page.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
configMapGenerator:
- name: bulwark-admin-config
files:
- config.json
- policy.json
patches:
- path: config-from-files.yaml
@@ -0,0 +1,5 @@
{
"restrictions": {},
"features": {},
"defaults": {}
}
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Smoke test for a running deployment of deploy/kubernetes:
# - Valkey and every Bulwark replica become ready, on the shared store;
# - an admin session made on one replica is valid on every other one;
# - a config change saved on one replica reaches the others within 5 s;
# - deleting a pod and a rolling restart don't fail requests through the
# Service, and the admin session survives the new pods.
#
# Usage: ADMIN_PASSWORD=... deploy/kubernetes/test/smoke.sh [namespace]
# With READ_ONLY_CONFIG=1 (overlays/config-in-git) it checks that config saves
# are refused instead of spread.
# Uses the current kubectl context. The password goes to curl on stdin, never
# on a command line.
set -euo pipefail
NS="${1:-bulwark}"
: "${ADMIN_PASSWORD:?set ADMIN_PASSWORD to the admin password of the deployment}"
k() { kubectl -n "$NS" "$@"; }
fail() { echo "FAIL: $*" >&2; exit 1; }
step() { echo "== $*"; }
step "rollout"
k rollout status statefulset/valkey --timeout=300s
k rollout status deployment/bulwark --timeout=600s
step "curl pod"
if ! k get pod smoke-curl >/dev/null 2>&1; then
k run smoke-curl --image=curlimages/curl:8.11.1 --restart=Never --labels=app.kubernetes.io/name=smoke-curl \
--command -- sleep 1800 >/dev/null
fi
k wait pod/smoke-curl --for=condition=Ready --timeout=180s >/dev/null
c() { k exec -i smoke-curl -- curl -sS --max-time 10 "$@"; }
status() { k exec smoke-curl -- curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$@" || echo 000; }
pod_ips() { k get pods -l app.kubernetes.io/name=bulwark --field-selector=status.phase=Running -o jsonpath='{.items[*].status.podIP}'; }
read -r -a IPS <<<"$(pod_ips)"
[ "${#IPS[@]}" -ge 2 ] || fail "need at least 2 replicas, found ${#IPS[@]}"
echo "replicas: ${IPS[*]}"
step "every replica is ready on the shared store"
for ip in "${IPS[@]}"; do
out="$(c "http://$ip:3000/api/ready")"
echo "$ip $out"
grep -q '"ready":true' <<<"$out" || fail "$ip not ready"
grep -q '"store":"redis"' <<<"$out" || fail "$ip not on the shared store"
done
step "an admin session made on one replica works on every replica"
json_string() {
local s="${1//\\/\\\\}"
s="${s//\"/\\\"}"
printf '"%s"' "$s"
}
code="$(printf '{"password":%s}' "$(json_string "$ADMIN_PASSWORD")" \
| c -o /dev/null -w '%{http_code}' -c /tmp/jar -H 'Content-Type: application/json' --data-binary @- \
"http://${IPS[0]}:3000/api/admin/auth")"
[ "$code" = 200 ] || fail "admin sign-in on ${IPS[0]} answered $code"
# curl's jar keeps cookies per host and every pod has its own address, so the
# cookie goes in a header. It stays in the curl pod, off every command line.
k exec smoke-curl -- sh -c "awk '{ sub(/^#HttpOnly_/, \"\") } !/^#/ && NF >= 7 { printf \"%s=%s; \", \$6, \$7 }' /tmp/jar > /tmp/cookie"
ca() { k exec -i smoke-curl -- sh -c 'curl -sS --max-time 10 -H "Cookie: $(cat /tmp/cookie)" "$@"' sh "$@"; }
for ip in "${IPS[@]}"; do
ca "http://$ip:3000/api/admin/auth" </dev/null | grep -q '"authenticated":true' || fail "admin session not valid on $ip"
done
if [ "${READ_ONLY_CONFIG:-}" = 1 ]; then
step "with config from files, every replica refuses a config save (409)"
for ip in "${IPS[@]}"; do
code="$(ca -o /dev/null -w '%{http_code}' -X PATCH -H 'Content-Type: application/json' \
--data '{"appName":"not-saved"}' "http://$ip:3000/api/admin/config" </dev/null)"
[ "$code" = 409 ] || fail "$ip answered $code to a save into read-only config"
done
else
step "a config change on one replica reaches the others"
name="smoke-$(date +%s)"
code="$(ca -o /dev/null -w '%{http_code}' -X PATCH -H 'Content-Type: application/json' \
--data "{\"appName\":\"$name\"}" "http://${IPS[0]}:3000/api/admin/config" </dev/null)"
[ "$code" = 200 ] || fail "config save answered $code"
for ip in "${IPS[@]:1}"; do
ok=""
for _ in $(seq 1 25); do
if c "http://$ip:3000/api/config" </dev/null | grep -q "\"appName\":\"$name\""; then ok=1; break; fi
sleep 0.2
done
[ -n "$ok" ] || fail "$ip didn't see the change within 5 s"
done
ca -o /dev/null -X DELETE -H 'Content-Type: application/json' --data '{"key":"appName"}' \
"http://${IPS[0]}:3000/api/admin/config" </dev/null
fi
SVC="http://bulwark.$NS.svc/api/ready"
probe_while() {
# Ask the Service once a second while "$@" runs; count failed answers.
local failures=0 total=0
"$@" &
local pid=$!
while kill -0 "$pid" 2>/dev/null; do
total=$((total + 1))
[ "$(status "$SVC")" = 200 ] || failures=$((failures + 1))
sleep 1
done
wait "$pid"
echo "requests: $total, failed: $failures"
[ "$failures" -eq 0 ] || fail "$failures requests failed"
}
step "deleting a pod keeps the Service answering"
victim="$(k get pods -l app.kubernetes.io/name=bulwark -o jsonpath='{.items[0].metadata.name}')"
probe_while k delete pod "$victim" --wait=true
k rollout status deployment/bulwark --timeout=300s
step "a rolling restart keeps the Service answering"
k rollout restart deployment/bulwark
probe_while k rollout status deployment/bulwark --timeout=600s
step "the admin session survives the new pods"
ca "http://bulwark.$NS.svc/api/admin/auth" </dev/null | grep -q '"authenticated":true' || fail "admin session lost"
k delete pod smoke-curl --wait=false >/dev/null
echo "OK"
@@ -0,0 +1,76 @@
/**
* @vitest-environment node
*
* On HOSTNAME=127.0.0.1, Next renames the host in request.nextUrl to
* "localhost" while its router keeps 127.0.0.1. A proxy rewrite or redirect
* built from nextUrl then points at another origin: rewrites were proxied
* back to the server itself (over TLS when X-Forwarded-Proto said https, an
* EPROTO 500), and with skipProxyUrlNormalize redirects would send the
* browser to localhost (#1046, #1487).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
let setupState: 'configured' | 'bootstrap' = 'configured';
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: vi.fn(async () => {}),
get: vi.fn((_key: string, fallback: unknown) => fallback),
getPolicy: vi.fn(() => ({ features: {} })),
},
}));
vi.mock('@/lib/setup/state', () => ({ detectSetupState: vi.fn(() => setupState) }));
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';
beforeEach(() => {
// What the build inlines for skipProxyUrlNormalize: true. NextRequest then
// keeps request.url as given instead of the renamed nextUrl.
vi.stubEnv('__NEXT_NO_MIDDLEWARE_URL_NORMALIZE', 'true');
});
afterEach(() => {
setupState = 'configured';
vi.unstubAllEnvs();
vi.resetModules();
});
describe('proxy redirects on a loopback listen address', () => {
it('sends a configured install from /setup to /admin/login on the same origin', async () => {
const response = await proxy(new NextRequest('https://127.0.0.1:3000/setup'));
expect(response.status).toBe(307);
expect(response.headers.get('location')).toBe('https://127.0.0.1:3000/admin/login');
});
it('sends every page to /setup on the same origin while the wizard runs, keeping the query', async () => {
setupState = 'bootstrap';
const response = await proxy(new NextRequest('http://[::1]:3000/mail?x=1'));
expect(response.status).toBe(307);
expect(response.headers.get('location')).toBe('http://[::1]:3000/setup?x=1');
});
it('keeps the base path of a sub-path install', async () => {
const request = new NextRequest('https://127.0.0.1:3000/webmail/setup', {
nextConfig: { basePath: '/webmail' },
});
const response = await proxy(request);
expect(response.headers.get('location')).toBe('https://127.0.0.1:3000/webmail/admin/login');
});
});
describe('next.config', () => {
it('leaves proxy rewrites on the URL the router built', async () => {
vi.resetModules();
const { default: config } = await import('../../next.config');
expect(config.skipProxyUrlNormalize).toBe(true);
});
});
@@ -0,0 +1,59 @@
// @vitest-environment node
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
import { existsSync, rmSync } from 'node:fs';
import path from 'node:path';
// A config dir mounted read-only (Kubernetes ConfigMap, a :ro volume) without
// an admin.json, and ADMIN_PASSWORD in the environment: the admin password
// works from memory on every replica instead of failing the start, which
// kept every replica of a shared-store deployment from ever becoming ready.
// Hoisted: isConfigReadOnly() reads the env once.
const dir = await vi.hoisted(async () => {
const { mkdtempSync, mkdirSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const root = mkdtempSync(join(tmpdir(), 'bw-admin-pw-ro-'));
mkdirSync(join(root, 'config'));
process.env.ADMIN_CONFIG_DIR = join(root, 'config');
process.env.ADMIN_STATE_DIR = join(root, 'state');
process.env.ADMIN_CONFIG_READONLY = 'true';
process.env.ADMIN_PASSWORD = 'from-the-environment';
return root;
});
import { initAdminPassword, isAdminEnabled, resetAdminCacheForTests, verifyAdminPassword } from '../password';
import { setStoreForTests } from '@/lib/shared-store';
import { resetLocalStoreForTests } from '@/lib/shared-store/local';
import { separateFakeStore } from './shared-store-fake';
afterEach(() => {
resetAdminCacheForTests();
setStoreForTests(null);
resetLocalStoreForTests();
});
afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});
describe('ADMIN_PASSWORD with a read-only config and no admin.json', () => {
it('works from memory on a single instance and writes nothing to the config dir', async () => {
await expect(initAdminPassword()).resolves.toBe(true);
expect(isAdminEnabled()).toBe(true);
expect(await verifyAdminPassword('from-the-environment')).toBe(true);
expect(await verifyAdminPassword('wrong')).toBe(false);
expect(existsSync(path.join(dir, 'config', 'admin.json'))).toBe(false);
});
it('lets every replica of a shared store start and sign in', async () => {
setStoreForTests(separateFakeStore(path.join(dir, 'store')));
for (let replica = 0; replica < 3; replica++) {
resetAdminCacheForTests();
await expect(initAdminPassword()).resolves.toBe(true);
expect(await verifyAdminPassword('from-the-environment')).toBe(true);
expect(await verifyAdminPassword('wrong')).toBe(false);
}
expect(existsSync(path.join(dir, 'config', 'admin.json'))).toBe(false);
});
});
+31 -1
View File
@@ -2,7 +2,7 @@ import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import { logger } from '@/lib/logger';
import { isSharedMode, type DocCollection, type PutOptions, type PutResult, type StoredDoc } from '@/lib/shared-store';
import { announceChange, onSharedChange } from '@/lib/shared-store/changes';
import { assertWritable } from './paths';
import { assertWritable, isConfigReadOnly } from './paths';
import { adminConfigDocs, adminStateDocs, isStoreUnavailable } from './admin-store';
import type { AdminConfigData, AdminStateData } from './types';
import { readFileEnv } from '../read-file-env';
@@ -131,6 +131,12 @@ interface AdminCache {
initialized: boolean;
/** Shared mode: one read of the state for checks that run together. */
stateRead?: Promise<AdminStateData | null>;
/**
* The password came from ADMIN_PASSWORD into a read-only config without an
* admin.json: it lives in this process only, and every replica makes it
* from the same variable on start.
*/
fromEnvOnly?: boolean;
unlisten?: () => void;
}
@@ -149,6 +155,7 @@ export function resetAdminCacheForTests(): void {
cache.initialized = false;
cache.stateRead = undefined;
cache.unlisten = undefined;
cache.fromEnvOnly = undefined;
}
function freshState(): AdminStateData {
@@ -271,6 +278,24 @@ export async function initAdminPassword(): Promise<boolean> {
const hash = isHashed(envPassword) ? envPassword : await hashPassword(envPassword);
const config: AdminConfigData = { passwordHash: hash, passwordHashFile: undefined };
if (isConfigReadOnly()) {
// Nowhere to save admin.json (a ConfigMap, a :ro volume). Keep the
// password in memory instead of failing the start; each replica does the
// same from the same variable. Changing it in the admin console is
// refused, as every other write to read-only config is.
cache.config = config;
cache.fromEnvOnly = true;
cache.state = shared ? await readStateData() : (await readStateData()) ?? freshState();
if (shared && !cache.state) {
const fresh = freshState();
cache.state = (await writeStateData(fresh, { ifVersion: null })).ok ? fresh : (await readStateData()) ?? fresh;
}
if (!cache.initialized) {
logger.info('Admin dashboard enabled with ADMIN_PASSWORD (config is read-only, so it is not saved to admin.json)');
}
cache.initialized = true;
return true;
}
if (shared) {
// Replicas booting together with ADMIN_PASSWORD: the first one writes
// admin.json and moves the session generation on, the others use it.
@@ -319,6 +344,11 @@ function verifyAgainst(config: AdminConfigData, password: string): Promise<boole
* replica counts at once.
*/
export async function verifyAdminPassword(password: string): Promise<boolean> {
if (cache.fromEnvOnly && cache.config) {
// A mounted admin.json that appeared later still wins.
const mounted = (await readConfigDoc())?.data ?? null;
return verifyAgainst(mounted ?? cache.config, password);
}
if (isSharedMode()) {
cache.config = (await readConfigDoc())?.data ?? null;
} else if (!cache.config) {
+9
View File
@@ -59,6 +59,15 @@ const nextConfig: NextConfig = {
// No `X-Powered-By: Next.js`; headers() below sends
// `X-Powered-By: bulwarkmail.org` instead, without a version.
poweredByHeader: false,
// Keep proxy rewrites on the origin the server listens on. Next renames a
// 127.x or [::1] host to "localhost" in each proxy rewrite, but its router
// compares the rewrite with the listen address. On HOSTNAME=127.0.0.1 every
// locale rewrite then looked external and was proxied back to the server
// itself, over TLS when X-Forwarded-Proto said https: EPROTO and a 500
// behind a reverse proxy (#1046, #1487, vercel/next.js#94745). With this set,
// request.url is the URL the router built; proxy.ts builds its redirects
// from it as well.
skipProxyUrlNormalize: true,
// Directory-style pages (out/en/mail/index.html) are what static hosts and
// the shipped SPA-fallback rules expect; next/image has no optimizer here.
...(isLite ? { trailingSlash: true, images: { unoptimized: true } } : {}),
+13 -8
View File
@@ -189,6 +189,17 @@ function isSetupPath(pathname: string): boolean {
);
}
/**
* A redirect to `pathname` (without the base path) on the origin of
* request.url. Not request.nextUrl: Next renames a 127.x or [::1] host there
* to "localhost", and with skipProxyUrlNormalize (next.config.ts) Next no
* longer turns that back into a relative Location. On HOSTNAME=127.0.0.1 the
* browser would be sent to https://localhost:3000/... (#1487).
*/
function redirectTo(request: NextRequest, pathname: string, search: string): NextResponse {
return NextResponse.redirect(new URL(`${request.nextUrl.basePath}${pathname}${search}`, request.url));
}
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). The metrics
@@ -234,10 +245,7 @@ export async function proxy(request: NextRequest) {
{ status: 503, headers: { "content-type": "application/json" } },
);
}
const url = request.nextUrl.clone();
url.pathname = "/setup";
url.search = request.nextUrl.search;
return NextResponse.redirect(url);
return redirectTo(request, "/setup", request.nextUrl.search);
}
} else if (isSetupPath(pathname)) {
// Configured / env-managed: wizard is no longer reachable.
@@ -247,10 +255,7 @@ export async function proxy(request: NextRequest) {
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);
return redirectTo(request, "/admin/login", "");
}
// Outer CSRF gate for every API route (GHSA-qvr9-m8cq-7wvg,