mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
Merge branch 'feat/enterprise-pack' into feat/enterprise-pack-f
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
sourcemaps
|
||||
lite-image
|
||||
.git
|
||||
.gitignore
|
||||
@@ -10,6 +11,8 @@ lite-image
|
||||
scripts/
|
||||
!scripts/lite/
|
||||
!scripts/build-plugin-sandbox.mjs
|
||||
!scripts/healthcheck.mjs
|
||||
!scripts/sourcemaps.mjs
|
||||
# Built in the image by scripts/build-plugin-sandbox.mjs
|
||||
public/plugin-sandbox-assets/
|
||||
TODO.md
|
||||
|
||||
@@ -181,6 +181,11 @@ jobs:
|
||||
cache-from: type=gha,scope=lite
|
||||
cache-to: type=gha,mode=max,scope=lite
|
||||
|
||||
# No source maps for this image yet: with BULWARK_SOURCE_MAPS=1 the
|
||||
# multi-platform build stalled on the runner after the Lite build step
|
||||
# (twice, 2026-11-01), though it finishes locally. The static Lite zips
|
||||
# (static-lite.yml) still upload theirs.
|
||||
|
||||
- name: Inspect image
|
||||
run: docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
|
||||
@@ -59,10 +59,34 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ github.sha }}
|
||||
BULWARK_SOURCE_MAPS=1
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
|
||||
# The same build again, a cache hit on this builder, for the stage that
|
||||
# holds only the source maps (Dockerfile, "sourcemaps"); the image never
|
||||
# has them. Both platforms build identical chunks, so amd64 uploads.
|
||||
# https://bulwarkmail.org/docs/deployment/monitoring
|
||||
- name: Export source maps
|
||||
if: matrix.platform == 'linux/amd64'
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
target: sourcemaps
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ github.sha }}
|
||||
BULWARK_SOURCE_MAPS=1
|
||||
outputs: type=local,dest=sourcemaps
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
|
||||
- name: Upload source maps to Bugsink
|
||||
if: matrix.platform == 'linux/amd64'
|
||||
env:
|
||||
BUGSINK_AUTH_TOKEN: ${{ secrets.BUGSINK_AUTH_TOKEN }}
|
||||
run: bash scripts/upload-sourcemaps.sh sourcemaps
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
@@ -184,10 +208,35 @@ jobs:
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ github.sha }}
|
||||
NEXT_PUBLIC_LOCALE_PREFIX=always
|
||||
BULWARK_SOURCE_MAPS=1
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=always-${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=always-${{ matrix.platform }}
|
||||
|
||||
# The same build again, a cache hit on this builder, for the stage that
|
||||
# holds only the source maps (Dockerfile, "sourcemaps"); the image never
|
||||
# has them. Both platforms build identical chunks, so amd64 uploads.
|
||||
# https://bulwarkmail.org/docs/deployment/monitoring
|
||||
- name: Export source maps
|
||||
if: matrix.platform == 'linux/amd64'
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
target: sourcemaps
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ github.sha }}
|
||||
NEXT_PUBLIC_LOCALE_PREFIX=always
|
||||
BULWARK_SOURCE_MAPS=1
|
||||
outputs: type=local,dest=sourcemaps
|
||||
cache-from: type=gha,scope=always-${{ matrix.platform }}
|
||||
|
||||
- name: Upload source maps to Bugsink
|
||||
if: matrix.platform == 'linux/amd64'
|
||||
env:
|
||||
BUGSINK_AUTH_TOKEN: ${{ secrets.BUGSINK_AUTH_TOKEN }}
|
||||
run: bash scripts/upload-sourcemaps.sh sourcemaps
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests-always
|
||||
|
||||
@@ -18,6 +18,9 @@ on:
|
||||
- "public/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "scripts/healthcheck.mjs"
|
||||
- "scripts/sourcemaps.mjs"
|
||||
- "scripts/upload-sourcemaps.sh"
|
||||
- ".github/workflows/docker-publish.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -89,10 +92,34 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ github.sha }}
|
||||
BULWARK_SOURCE_MAPS=1
|
||||
outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
|
||||
# The same build again, a cache hit on this builder, for the stage that
|
||||
# holds only the source maps (Dockerfile, "sourcemaps"); the image never
|
||||
# has them. Both platforms build identical chunks, so amd64 uploads.
|
||||
# https://bulwarkmail.org/docs/deployment/monitoring
|
||||
- name: Export source maps
|
||||
if: matrix.platform == 'linux/amd64'
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
target: sourcemaps
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ github.sha }}
|
||||
BULWARK_SOURCE_MAPS=1
|
||||
outputs: type=local,dest=sourcemaps
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
|
||||
- name: Upload source maps to Bugsink
|
||||
if: matrix.platform == 'linux/amd64'
|
||||
env:
|
||||
BUGSINK_AUTH_TOKEN: ${{ secrets.BUGSINK_AUTH_TOKEN }}
|
||||
run: bash scripts/upload-sourcemaps.sh sourcemaps
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
|
||||
@@ -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
|
||||
@@ -40,9 +40,26 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# With browser and server source maps; Turbopack gives every chunk a
|
||||
# debug ID. The maps go to Bugsink and never into the tarball.
|
||||
# https://bulwarkmail.org/docs/deployment/monitoring
|
||||
- name: Build standalone
|
||||
env:
|
||||
BULWARK_SOURCE_MAPS: "1"
|
||||
run: npm run build
|
||||
|
||||
# .next/server holds the server chunks' maps; standalone copies the
|
||||
# chunks only. It doesn't ship, but its maps are needed for server frames.
|
||||
- name: Move source maps out of the build
|
||||
run: node scripts/sourcemaps.mjs collect --out sourcemaps .next/static .next/standalone .next/server
|
||||
|
||||
# Turbopack chunks were not checked for being the same on both
|
||||
# platforms, so each one uploads its own.
|
||||
- name: Upload source maps to Bugsink
|
||||
env:
|
||||
BUGSINK_AUTH_TOKEN: ${{ secrets.BUGSINK_AUTH_TOKEN }}
|
||||
run: bash scripts/upload-sourcemaps.sh sourcemaps
|
||||
|
||||
- name: Package tarball
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
@@ -59,6 +76,9 @@ jobs:
|
||||
tar -czf "$TARBALL" bulwark-standalone/
|
||||
echo "TARBALL=$TARBALL" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check the tarball holds no source maps
|
||||
run: node scripts/sourcemaps.mjs check "$TARBALL"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
|
||||
@@ -79,8 +79,18 @@ jobs:
|
||||
LITE_APP_NAME: ${{ inputs.app_name }}
|
||||
LITE_LOCALES: ${{ inputs.locales }}
|
||||
LITE_DEMO_MODE: ${{ inputs.demo }}
|
||||
# Browser source maps for Bugsink; build:lite moves them out of out/
|
||||
# before anything is packed. https://bulwarkmail.org/docs/deployment/monitoring
|
||||
BULWARK_SOURCE_MAPS: "1"
|
||||
SOURCEMAPS_OUT: sourcemaps
|
||||
run: npm run build:lite
|
||||
|
||||
# Each target is its own build with its own chunks, so each uploads.
|
||||
- name: Upload source maps to Bugsink
|
||||
env:
|
||||
BUGSINK_AUTH_TOKEN: ${{ secrets.BUGSINK_AUTH_TOKEN }}
|
||||
run: bash scripts/upload-sourcemaps.sh sourcemaps
|
||||
|
||||
# Every host literal in the bundle must be a placeholder, the configured
|
||||
# JMAP server, a built-in host the no-third-party switch turns off, or a
|
||||
# string listed as never requested (#903).
|
||||
@@ -107,6 +117,9 @@ jobs:
|
||||
echo "ZIP=${ZIP}" >> "$GITHUB_ENV"
|
||||
ls -la "${ZIP}"
|
||||
|
||||
- name: Check the zip holds no source maps
|
||||
run: node scripts/sourcemaps.mjs check "$ZIP"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
# Source maps CI collects for the error tracker (scripts/sourcemaps.mjs)
|
||||
/sourcemaps/
|
||||
/lite-image/
|
||||
# scripts/build-plugin-sandbox.mjs
|
||||
/public/plugin-sandbox-assets/
|
||||
@@ -57,3 +59,6 @@ next-env.d.ts
|
||||
|
||||
# Sibling repos
|
||||
/repos/
|
||||
|
||||
# Kubernetes reference manifests: the secrets users fill in
|
||||
deploy/kubernetes/base/secrets.env
|
||||
|
||||
+25
@@ -21,6 +21,10 @@ ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
|
||||
# `git rev-parse` inside the build can't find it - CI must pass it in.
|
||||
ARG GIT_COMMIT=unknown
|
||||
ENV GIT_COMMIT=$GIT_COMMIT
|
||||
# Browser source maps for the error tracker: CI passes 1, a plain build makes
|
||||
# none (next.config.ts reads it). They never reach the image, see below.
|
||||
ARG BULWARK_SOURCE_MAPS=0
|
||||
ENV BULWARK_SOURCE_MAPS=$BULWARK_SOURCE_MAPS
|
||||
# The mock JMAP server (DEV_MOCK_JMAP) accepts any password and is for
|
||||
# `npm run dev` only; images ship without it.
|
||||
# `next build` type-checks every .ts file, and some unit tests import the
|
||||
@@ -34,6 +38,21 @@ RUN rm -rf "app/api/dev-jmap" && \
|
||||
node scripts/build-plugin-sandbox.mjs && \
|
||||
npx next build --webpack
|
||||
|
||||
# webpack has no debug IDs of its own, so sentry-cli adds them to the
|
||||
# chunks; then every map moves to /sourcemaps (exported with
|
||||
# `--target sourcemaps`) and the check fails the build if one would ship.
|
||||
# https://bulwarkmail.org/docs/deployment/monitoring
|
||||
ARG SENTRY_CLI_VERSION=3.8.2
|
||||
RUN if [ "$BULWARK_SOURCE_MAPS" = "1" ]; then \
|
||||
npx --yes "@sentry/cli@${SENTRY_CLI_VERSION}" sourcemaps inject .next/static; \
|
||||
fi && \
|
||||
node scripts/sourcemaps.mjs collect --out /sourcemaps .next/static .next/standalone && \
|
||||
node scripts/sourcemaps.mjs check .next/static .next/standalone public
|
||||
|
||||
# Only the maps, for CI to upload: never part of the image.
|
||||
FROM scratch AS sourcemaps
|
||||
COPY --from=builder /sourcemaps /
|
||||
|
||||
FROM node:24-alpine AS runner
|
||||
|
||||
LABEL org.opencontainers.image.title="Bulwark Webmail"
|
||||
@@ -57,6 +76,7 @@ RUN apk upgrade --no-cache && \
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/scripts/healthcheck.mjs ./healthcheck.mjs
|
||||
# Every data dir exists up front and belongs to the runtime user, so a fresh
|
||||
# volume on /app/data (Docker copies these into it) or on any one of them is
|
||||
# writable. plugin-secrets holds users' plugin credentials, version-check the
|
||||
@@ -69,4 +89,9 @@ USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
# The health check needs the base path the bundle was built with.
|
||||
ARG NEXT_PUBLIC_BASE_PATH=
|
||||
ENV BULWARK_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
||||
# Liveness only (/api/health/live); readiness is /api/health/ready.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 CMD ["node", "healthcheck.mjs"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ErrorState } from "@/components/error/error-fallbacks";
|
||||
import { captureException } from "@/lib/error-reporting/client";
|
||||
|
||||
/**
|
||||
* Route-level error boundary for locale pages.
|
||||
@@ -19,6 +20,7 @@ export default function LocaleError({
|
||||
|
||||
useEffect(() => {
|
||||
console.error("Route error:", error);
|
||||
captureException(error, "boundary", false);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -24,6 +24,7 @@ import { MarketplaceDetailPage } from '../_pages/marketplace-detail';
|
||||
import { ExtensionDetailPage } from '../_pages/extension-detail';
|
||||
import { VersionPage } from '../_pages/version';
|
||||
import { TelemetryPage } from '../_pages/telemetry';
|
||||
import { MonitoringPage } from '../_pages/monitoring';
|
||||
import { LogsPage } from '../_pages/logs';
|
||||
|
||||
/**
|
||||
@@ -64,6 +65,7 @@ export default function AdminPage() {
|
||||
case 'marketplace': return detail ? <MarketplaceDetailPage key={detail} slug={detail} /> : <MarketplacePage />;
|
||||
case 'version': return <VersionPage />;
|
||||
case 'telemetry': return <TelemetryPage />;
|
||||
case 'monitoring': return <MonitoringPage />;
|
||||
case 'logs': return <LogsPage />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Loader2 } from '@/components/icons';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { adminPageDef } from '@/lib/admin/admin-nav';
|
||||
import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store';
|
||||
import { SettingsSection, SectionNav } from '@/components/settings/settings-section';
|
||||
import { AdminPageHeader } from '@/components/admin/page-header';
|
||||
import { AdminSaveBar } from '@/components/admin/save-bar';
|
||||
import { withEdit } from '@/lib/admin/edits';
|
||||
import { useAdminFeedback } from '@/components/admin/feedback';
|
||||
import { ConfigTextItem, ConfigNote } from '@/components/admin/config-items';
|
||||
|
||||
interface ConfigEntry {
|
||||
value?: unknown;
|
||||
source: 'admin' | 'env' | 'default';
|
||||
}
|
||||
|
||||
interface ErrorEntry {
|
||||
fingerprint: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
version: string;
|
||||
runtime: 'browser' | 'server';
|
||||
count: number;
|
||||
frames: Array<{ path: string; line?: number; column?: number }>;
|
||||
}
|
||||
|
||||
interface MonitoringState {
|
||||
collecting: boolean;
|
||||
day: string;
|
||||
errorsToday: { total: number; top: ErrorEntry[] };
|
||||
metrics?: { enabled: boolean };
|
||||
tracing?: { configured: boolean; active: boolean; endpointHost: string | null };
|
||||
}
|
||||
|
||||
const PAGE = adminPageDef('monitoring');
|
||||
const KEYS = ['errorReportingDsn', 'errorReportingSampleRate', 'errorReportingEnvironment'] as const;
|
||||
const DOCS = 'https://bulwarkmail.org/docs/deployment/monitoring';
|
||||
|
||||
export function MonitoringPage() {
|
||||
const [config, setConfig] = useState<Record<string, ConfigEntry>>({});
|
||||
const [state, setState] = useState<MonitoringState | null>(null);
|
||||
const [edits, setEdits] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const setMessage = useAdminFeedback();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [configRes, stateRes] = await Promise.all([apiFetch('/api/admin/config'), apiFetch('/api/admin/monitoring')]);
|
||||
if (configRes.ok) setConfig(await configRes.json());
|
||||
if (stateRes.ok) setState(await stateRes.json());
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
function handleChange(key: string, value: unknown) {
|
||||
setEdits((prev) => withEdit(prev, key, value, config[key]?.value));
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function currentValue(key: string): string {
|
||||
const value = key in edits ? edits[key] : config[key]?.value;
|
||||
return typeof value === 'string' ? value : value === undefined || value === null ? '' : String(value);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (Object.keys(edits).length === 0) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(edits),
|
||||
});
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Saved. Browsers pick it up on their next page load.' });
|
||||
setEdits({});
|
||||
await load();
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleRevert(key: string) {
|
||||
const res = await apiFetch('/api/admin/config', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setEdits((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
const hasEdits = KEYS.some((key) => key in edits);
|
||||
useAdminUnsavedChanges('monitoring', hasEdits);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const today = state?.errorsToday;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader title={PAGE.label} description={PAGE.description} />
|
||||
<div className="space-y-10">
|
||||
<SectionNav />
|
||||
|
||||
<SettingsSection id="errors" title="Error reporting">
|
||||
<ConfigNote>
|
||||
Send errors from browsers and from this server to your own error tracker: Bugsink, GlitchTip or
|
||||
Sentry. Off while the address is empty. Message text, addresses, file names, tokens and URLs are
|
||||
removed before anything is sent. <a className="underline" href={DOCS} target="_blank" rel="noreferrer">What is sent</a>
|
||||
</ConfigNote>
|
||||
<ConfigTextItem label="Error reporting address (DSN)" configKey="errorReportingDsn" value={currentValue('errorReportingDsn')} source={config.errorReportingDsn?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://<key>@errors.example.com/1" wide />
|
||||
<ConfigTextItem label="Sample rate" configKey="errorReportingSampleRate" value={currentValue('errorReportingSampleRate')} source={config.errorReportingSampleRate?.source} onChange={handleChange} onRevert={handleRevert} placeholder="1" />
|
||||
<ConfigTextItem label="Environment" configKey="errorReportingEnvironment" value={currentValue('errorReportingEnvironment')} source={config.errorReportingEnvironment?.source} onChange={handleChange} onRevert={handleRevert} placeholder="production" />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="errors-today" title="Errors today">
|
||||
{!state?.collecting ? (
|
||||
<ConfigNote>
|
||||
Not counting errors. Counting starts when an error reporting address is set, the metrics
|
||||
endpoint is on, or error counts are sent with the usage stats.
|
||||
</ConfigNote>
|
||||
) : !today || today.total === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No errors so far today (UTC).</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">{today.total} errors today (UTC), most frequent first.</p>
|
||||
<ul className="divide-y divide-border rounded-md border border-border text-sm">
|
||||
{today.top.map((entry) => (
|
||||
<li key={entry.fingerprint} className="flex items-start justify-between gap-4 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">{entry.name}{entry.code ? `: ${entry.code}` : ''}</div>
|
||||
<div className="truncate font-mono text-xs text-muted-foreground">
|
||||
{entry.frames[0] ? `${entry.frames[0].path}:${entry.frames[0].line ?? ''}:${entry.frames[0].column ?? ''}` : 'no stack'}
|
||||
{' · '}{entry.runtime}{' · '}{entry.version}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 tabular-nums">{entry.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="metrics" title="Metrics">
|
||||
<p className="text-sm">
|
||||
{state?.metrics?.enabled
|
||||
? <>On. Prometheus scrapes <code className="font-mono">/api/metrics</code> on each replica with the token as a bearer token.</>
|
||||
: <>Off. Set <code className="font-mono">METRICS_TOKEN</code> (or <code className="font-mono">METRICS_TOKEN_FILE</code>) in the environment to turn on <code className="font-mono">/api/metrics</code>.</>}
|
||||
</p>
|
||||
<ConfigNote>
|
||||
Requests per route group, sign-ins, calls to Stalwart and other services, browser errors, memory and
|
||||
event-loop lag. <a className="underline" href={`${DOCS}#metrics`} target="_blank" rel="noreferrer">Metric list and scrape config</a>
|
||||
</ConfigNote>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="health" title="Health checks">
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li><code className="font-mono">/api/health/live</code>: the process answers. For liveness probes and the Docker health check.</li>
|
||||
<li><code className="font-mono">/api/health/ready</code>: config loaded, data dirs writable, Stalwart reachable. For readiness probes.</li>
|
||||
</ul>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="tracing" title="Tracing">
|
||||
<p className="text-sm">
|
||||
{state?.tracing?.active
|
||||
? <>On. Spans go to <code className="font-mono">{state.tracing.endpointHost ?? 'the OTLP endpoint'}</code> over OTLP/HTTP.</>
|
||||
: state?.tracing?.configured
|
||||
? <>Configured, but not running in this process. Restart Bulwark after setting the <code className="font-mono">OTEL_*</code> variables.</>
|
||||
: <>Off. Set <code className="font-mono">OTEL_EXPORTER_OTLP_ENDPOINT</code> (and the other standard <code className="font-mono">OTEL_*</code> variables you need) to send traces.</>}
|
||||
</p>
|
||||
<ConfigNote>
|
||||
Next's request spans plus spans for calls to Stalwart and other services. Stalwart gets the trace
|
||||
context, so its spans join the trace. URLs, user agents and addresses are removed from spans.{' '}
|
||||
<a className="underline" href={`${DOCS}#tracing`} target="_blank" rel="noreferrer">Setup</a>
|
||||
</ConfigNote>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
|
||||
<AdminSaveBar
|
||||
dirty={hasEdits}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onDiscard={() => setEdits({})}
|
||||
note="Browsers pick it up on their next page load"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,15 +16,22 @@ import { cn } from '@/lib/utils';
|
||||
interface TelemetryStatus {
|
||||
consent: 'pending' | 'on' | 'off';
|
||||
consentSource: 'no-third-party' | 'env' | 'file';
|
||||
/** 'on' sends them daily; 'instant' also sends today's as they change. */
|
||||
errorsConsent?: ErrorsMode;
|
||||
errorsConsentSource?: 'telemetry' | 'no-third-party' | 'env' | 'file';
|
||||
endpoint: string;
|
||||
defaultEndpoint: string;
|
||||
consentedAt: string | null;
|
||||
lastSentAt: string | null;
|
||||
nextScheduledAt: string | null;
|
||||
payloadPreview: Record<string, unknown>;
|
||||
errorsPreview?: unknown[];
|
||||
instantErrorsPreview?: unknown[];
|
||||
accountCounts: { total: number; active7d: number };
|
||||
}
|
||||
|
||||
type ErrorsMode = 'on' | 'off' | 'instant';
|
||||
|
||||
const PAGE = adminPageDef('telemetry');
|
||||
|
||||
function timeAgo(iso: string | null): string {
|
||||
@@ -56,10 +63,12 @@ export function TelemetryPage() {
|
||||
// The heartbeat switch waits for Save like the endpoint (one page, one
|
||||
// save bar).
|
||||
const [consentDraft, setConsentDraft] = useState<'on' | 'off' | null>(null);
|
||||
const [errorsDraft, setErrorsDraft] = useState<ErrorsMode | null>(null);
|
||||
|
||||
const endpointDirty = status !== null && endpointDraft !== status.endpoint;
|
||||
const consentDirty = status !== null && consentDraft !== null && consentDraft !== status.consent;
|
||||
const dirty = endpointDirty || consentDirty;
|
||||
const errorsDirty = status !== null && errorsDraft !== null && errorsDraft !== (status.errorsConsent ?? 'off');
|
||||
const dirty = endpointDirty || consentDirty || errorsDirty;
|
||||
useAdminUnsavedChanges('telemetry', dirty);
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
@@ -99,11 +108,17 @@ export function TelemetryPage() {
|
||||
const error = await post({ action: 'set-consent', consent: consentDraft });
|
||||
if (error) errors.push(`Heartbeats: ${error}`);
|
||||
}
|
||||
// After the heartbeat switch: error counts need it on.
|
||||
if (errorsDirty && errorsDraft) {
|
||||
const error = await post({ action: 'set-errors-consent', consent: errorsDraft });
|
||||
if (error) errors.push(`Error counts: ${error}`);
|
||||
}
|
||||
if (endpointDirty) {
|
||||
const error = await post({ action: 'set-endpoint', endpoint: endpointDraft });
|
||||
if (error) errors.push(`Endpoint: ${error}`);
|
||||
}
|
||||
setConsentDraft(null);
|
||||
setErrorsDraft(null);
|
||||
setMessage(errors.length > 0
|
||||
? { type: 'error', text: `Couldn't save: ${errors.join('; ')}` }
|
||||
: { type: 'success', text: 'Telemetry settings saved.' });
|
||||
@@ -146,6 +161,25 @@ export function TelemetryPage() {
|
||||
const isOn = status.consent === 'on';
|
||||
const shownOn = (consentDraft ?? status.consent) === 'on';
|
||||
|
||||
// Error counts follow the heartbeat switch as it is about to be saved.
|
||||
const errorsLocked = noThirdParty || status.errorsConsentSource === 'env' || !shownOn;
|
||||
const errorsMode: ErrorsMode = shownOn ? (errorsDraft ?? status.errorsConsent ?? 'off') : 'off';
|
||||
const errorsShownOn = errorsMode !== 'off';
|
||||
const errorsDescription = [
|
||||
'Adds how often each error happened yesterday, the error name and where in the code it happened. No message text, no URLs, no instance id goes to the error tracker.',
|
||||
!shownOn && 'Needs the heartbeat on.',
|
||||
status.errorsConsentSource === 'env' && 'Locked by the BULWARK_TELEMETRY_ERRORS env var.',
|
||||
].filter(Boolean).join(' ');
|
||||
const instantDescription = [
|
||||
'Also sends today\'s counts within about a minute when an error is new or happens noticeably more often, so a broken release shows up the same day. The same fields as the daily counts; the collector learns roughly when errors happen.',
|
||||
!errorsShownOn && 'Needs error counts on.',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
// The payload as it will be once the switches are saved.
|
||||
const previewPayload = errorsShownOn
|
||||
? { ...status.payloadPreview, schema: '2', errors: status.errorsPreview ?? [] }
|
||||
: (({ errors: _omit, ...v1 }) => ({ ...v1, schema: '1' }))(status.payloadPreview as Record<string, unknown> & { errors?: unknown });
|
||||
|
||||
const consentDescription = [
|
||||
status.consent === 'pending' && 'Initialising - no heartbeats sent yet.',
|
||||
status.consent === 'on' && status.endpoint && 'Heartbeats are enabled. Thanks for helping us improve!',
|
||||
@@ -181,7 +215,25 @@ export function TelemetryPage() {
|
||||
<ToggleSwitch
|
||||
checked={shownOn}
|
||||
disabled={busy === 'save' || envOverridden}
|
||||
onChange={(checked) => { setConsentDraft(checked ? 'on' : 'off'); setMessage(null); }}
|
||||
onChange={(checked) => {
|
||||
setConsentDraft(checked ? 'on' : 'off');
|
||||
if (!checked) setErrorsDraft('off');
|
||||
setMessage(null);
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label="Also send anonymous error counts" description={errorsDescription}>
|
||||
<ToggleSwitch
|
||||
checked={errorsShownOn}
|
||||
disabled={busy === 'save' || errorsLocked}
|
||||
onChange={(checked) => { setErrorsDraft(checked ? 'on' : 'off'); setMessage(null); }}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label="Send error counts right away" description={instantDescription}>
|
||||
<ToggleSwitch
|
||||
checked={errorsMode === 'instant'}
|
||||
disabled={busy === 'save' || errorsLocked || !errorsShownOn}
|
||||
onChange={(checked) => { setErrorsDraft(checked ? 'instant' : 'on'); setMessage(null); }}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label="Last sent">
|
||||
@@ -252,8 +304,24 @@ export function TelemetryPage() {
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-md p-3 overflow-x-auto max-h-96">
|
||||
{JSON.stringify(status.payloadPreview, null, 2)}
|
||||
{JSON.stringify(previewPayload, null, 2)}
|
||||
</pre>
|
||||
{errorsMode === 'instant' && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sent right away: today's error counts as they would go out now. Each minute only
|
||||
the errors that are new or reached a higher count range are sent.
|
||||
</p>
|
||||
<pre className="text-xs bg-muted/50 rounded-md p-3 overflow-x-auto max-h-96">
|
||||
{JSON.stringify({
|
||||
schema: '1',
|
||||
instance_id: status.payloadPreview.instance_id,
|
||||
day: new Date().toISOString().slice(0, 10),
|
||||
errors: status.instantErrorsPreview ?? [],
|
||||
}, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</div>
|
||||
|
||||
@@ -261,7 +329,7 @@ export function TelemetryPage() {
|
||||
dirty={dirty}
|
||||
saving={busy === 'save'}
|
||||
onSave={() => void save()}
|
||||
onDiscard={() => { setEndpointDraft(status.endpoint); setConsentDraft(null); }}
|
||||
onDiscard={() => { setEndpointDraft(status.endpoint); setConsentDraft(null); setErrorsDraft(null); }}
|
||||
saveLabel="Save"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { headers } from "next/headers";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
import { FaviconBadge } from "@/components/favicon-badge";
|
||||
import { ErrorReportingRoot } from "@/components/error/error-reporting-root";
|
||||
import { ThemeColorSync } from "@/components/theme-color-sync";
|
||||
import { IosViewportHeal } from "@/components/ios-viewport-heal";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
@@ -328,6 +329,7 @@ export default async function RootLayout({
|
||||
<ServiceWorkerRegistration />
|
||||
{!themeColorConfigured && <ThemeColorSync />}
|
||||
<FaviconBadge />
|
||||
<ErrorReportingRoot />
|
||||
<IosViewportHeal />
|
||||
{children}
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdminAuth } from '@/lib/admin/session';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { readErrors, summarizeDay, utcDay } from '@/lib/error-reporting/aggregate';
|
||||
import { shouldCollectErrors } from '@/lib/error-reporting/settings';
|
||||
import { storedDataUnavailableResponse } from '@/lib/stored-data-errors';
|
||||
import { metricsEnabled } from '@/lib/metrics';
|
||||
import { isTracingActive, tracingEndpoint } from '@/lib/tracing/config';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/admin/monitoring: what System > Monitoring shows besides the
|
||||
* config keys (those come from /api/admin/config): whether errors are being
|
||||
* counted, today's counts, and whether metrics and tracing are on.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAdminAuth(request);
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const collecting = await shouldCollectErrors();
|
||||
const today = utcDay();
|
||||
const errorsToday = summarizeDay(await readErrors(), today);
|
||||
const metrics = { enabled: metricsEnabled() };
|
||||
const endpoint = tracingEndpoint();
|
||||
let endpointHost: string | null = null;
|
||||
try { endpointHost = endpoint ? new URL(endpoint).host : null; } catch { endpointHost = null; }
|
||||
const tracing = { configured: endpoint !== null, active: isTracingActive(), endpointHost };
|
||||
return NextResponse.json({ collecting, day: today, errorsToday, metrics, tracing }, { headers: { 'Cache-Control': 'no-store' } });
|
||||
} catch (err) {
|
||||
logger.error('monitoring GET error', { error: err instanceof Error ? err.message : 'unknown' });
|
||||
const unavailable = storedDataUnavailableResponse(err);
|
||||
if (unavailable) return unavailable;
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
effectiveConsent,
|
||||
effectiveErrorsConsent,
|
||||
updateState,
|
||||
buildPayload,
|
||||
sendOnce,
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
resolveEndpointAllowed,
|
||||
} from '@/lib/telemetry';
|
||||
import { storedDataUnavailableResponse } from '@/lib/stored-data-errors';
|
||||
import { errorsForDay, errorsForPayload } from '@/lib/telemetry/payload';
|
||||
import { readErrors, utcDay } from '@/lib/error-reporting/aggregate';
|
||||
|
||||
/**
|
||||
* GET /api/admin/telemetry
|
||||
@@ -25,21 +28,31 @@ export async function GET(request: NextRequest) {
|
||||
if ('error' in auth) return auth.error;
|
||||
|
||||
const { consent, source, state } = await effectiveConsent();
|
||||
const [payload, accountCounts] = await Promise.all([
|
||||
const [payload, accountCounts, errors, counted] = await Promise.all([
|
||||
buildPayload(),
|
||||
getLoginCounts(),
|
||||
effectiveErrorsConsent(),
|
||||
readErrors(),
|
||||
]);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
consent,
|
||||
consentSource: source,
|
||||
// The separate consent to add error counts (schema v2).
|
||||
errorsConsent: errors.consent,
|
||||
errorsConsentSource: errors.source,
|
||||
endpoint: state.endpoint ?? DEFAULT_ENDPOINT,
|
||||
consentedAt: state.consentedAt,
|
||||
lastSentAt: state.lastSentAt,
|
||||
nextScheduledAt: state.nextScheduledAt,
|
||||
defaultEndpoint: DEFAULT_ENDPOINT,
|
||||
payloadPreview: payload,
|
||||
// What `errors` would hold, so the page can show the v2 payload as soon
|
||||
// as the switch is flipped, before anything is saved or sent.
|
||||
errorsPreview: errorsForPayload(counted),
|
||||
// What instant mode would send now: today's counts so far.
|
||||
instantErrorsPreview: errorsForDay(counted, utcDay()),
|
||||
accountCounts,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
@@ -57,7 +70,8 @@ export async function GET(request: NextRequest) {
|
||||
/**
|
||||
* POST /api/admin/telemetry
|
||||
* Body: { action: 'set-consent' | 'set-endpoint' | 'send-now', ... }
|
||||
* set-consent : { action, consent: 'on' | 'off' }
|
||||
* set-consent : { action, consent: 'on' | 'off' } (off also switches error counts off)
|
||||
* set-errors-consent : { action, consent: 'on' | 'instant' | 'off' } ('on' is daily)
|
||||
* set-endpoint : { action, endpoint: string }
|
||||
* send-now : { action }
|
||||
*/
|
||||
@@ -100,12 +114,39 @@ export async function POST(request: NextRequest) {
|
||||
before = state.consent;
|
||||
state.consent = consent;
|
||||
if (consent === 'on' && !state.consentedAt) state.consentedAt = now;
|
||||
// Error counts ride on the heartbeat: no heartbeats, no error counts,
|
||||
// and turning heartbeats on again doesn't bring them back by itself.
|
||||
if (consent === 'off') state.errorsConsent = 'off';
|
||||
});
|
||||
await reschedule();
|
||||
await auditLog('telemetry.set-consent', { from: before, to: body.consent }, ip);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
if (body.action === 'set-errors-consent') {
|
||||
const errors = await effectiveErrorsConsent();
|
||||
if (errors.source === 'env') {
|
||||
return NextResponse.json({ error: 'error counts are set by the BULWARK_TELEMETRY_ERRORS env var' }, { status: 409 });
|
||||
}
|
||||
if (errors.source === 'no-third-party') {
|
||||
return NextResponse.json({ error: 'telemetry is off while NO_THIRD_PARTY_TRAFFIC is on' }, { status: 409 });
|
||||
}
|
||||
if (body.consent !== 'on' && body.consent !== 'off' && body.consent !== 'instant') {
|
||||
return NextResponse.json({ error: 'consent must be "on", "instant" or "off"' }, { status: 400 });
|
||||
}
|
||||
if (body.consent !== 'off' && errors.source === 'telemetry') {
|
||||
return NextResponse.json({ error: 'turn on the heartbeat first' }, { status: 409 });
|
||||
}
|
||||
const consent = body.consent;
|
||||
let before: string | undefined;
|
||||
await updateState((state) => {
|
||||
before = state.errorsConsent ?? 'off';
|
||||
state.errorsConsent = consent;
|
||||
});
|
||||
await auditLog('telemetry.set-errors-consent', { from: before, to: consent }, ip);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
if (body.action === 'set-endpoint') {
|
||||
if (typeof body.endpoint !== 'string') {
|
||||
return NextResponse.json({ error: 'endpoint required' }, { status: 400 });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { observeUpstream } from '@/lib/metrics/upstream';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import {
|
||||
@@ -57,7 +58,8 @@ function getSlot(request: NextRequest): number {
|
||||
const REVOCATION_TIMEOUT_MS = 3000;
|
||||
|
||||
async function revokeRefreshToken(token: string, serverId: string | null, metadata: OAuthMetadata | null): Promise<void> {
|
||||
if (!metadata?.revocation_endpoint) return;
|
||||
const revocationEndpoint = metadata?.revocation_endpoint;
|
||||
if (!revocationEndpoint) return;
|
||||
const params = buildOAuthParams({
|
||||
token,
|
||||
token_type_hint: 'refresh_token',
|
||||
@@ -66,13 +68,13 @@ async function revokeRefreshToken(token: string, serverId: string | null, metada
|
||||
try {
|
||||
// Never follow a redirect with the refresh token and client secret in
|
||||
// the body: the endpoint was validated, its redirect target was not.
|
||||
const revocationResponse = await fetch(metadata.revocation_endpoint, {
|
||||
const revocationResponse = await observeUpstream('oauth', () => fetch(revocationEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
signal: AbortSignal.timeout(REVOCATION_TIMEOUT_MS),
|
||||
redirect: 'error',
|
||||
});
|
||||
}));
|
||||
if (!revocationResponse.ok) {
|
||||
logger.warn('Token revocation returned error', { status: revocationResponse.status });
|
||||
}
|
||||
@@ -262,13 +264,13 @@ export async function PUT(request: NextRequest) {
|
||||
refresh_token: refreshToken,
|
||||
}, serverId, await defaultClientOptions(serverId));
|
||||
|
||||
const tokenResponse = await fetch(tokenEndpoint, {
|
||||
const tokenResponse = await observeUpstream('oauth', () => fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
// See revokeRefreshToken: the body holds the refresh token.
|
||||
redirect: 'error',
|
||||
});
|
||||
}));
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type BrandingOverrideKey,
|
||||
} from '@/lib/admin/domain-branding';
|
||||
import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
|
||||
import { browserErrorSettings } from '@/lib/error-reporting/settings';
|
||||
|
||||
/**
|
||||
* Runtime configuration endpoint
|
||||
@@ -109,6 +110,9 @@ export async function GET(request: NextRequest) {
|
||||
signLanguageUrl: httpUrlOrEmpty(branded<string>('signLanguageUrl', '')),
|
||||
// Browser-side switches of lib/outgoing-hosts.ts read this (#903).
|
||||
noThirdPartyTraffic: await isNoThirdPartyTrafficOn(),
|
||||
// Error reporting (lib/error-reporting/client.ts): off unless a DSN is
|
||||
// set or this server counts errors. The DSN is public by design.
|
||||
errorReporting: await browserErrorSettings(),
|
||||
},
|
||||
{
|
||||
// Runtime settings can change without new asset URLs. Prevent stale
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
const config: Record<string, unknown> = {};
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: async () => {},
|
||||
get: (key: string, fallback: unknown) => (key in config ? config[key] : fallback),
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from '../route';
|
||||
import { flushErrors, readErrors, resetAggregateForTests, utcDay } from '@/lib/error-reporting/aggregate';
|
||||
import { metrics, resetMetricsForTests } from '@/lib/metrics';
|
||||
|
||||
const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3';
|
||||
const SUBJECT = 'Merger plans for Acme Q3';
|
||||
|
||||
let dir: string;
|
||||
let ip = 0;
|
||||
|
||||
function post(body: unknown): NextRequest {
|
||||
ip += 1;
|
||||
return new NextRequest('http://localhost/api/errors', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-forwarded-for': `203.0.113.${ip}` },
|
||||
body: typeof body === 'string' ? body : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const report = {
|
||||
name: 'TypeError',
|
||||
message: `Cannot open "${SUBJECT}"`,
|
||||
frames: [{ path: `https://mail.example.org/_next/static/chunks/1.js?subject=${encodeURIComponent(SUBJECT)}`, line: 2, column: 30, fn: 'Ab' }],
|
||||
version: '1.20.0',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), 'bulwark-errors-route-'));
|
||||
process.env.TELEMETRY_DATA_DIR = dir;
|
||||
for (const key of Object.keys(config)) delete config[key];
|
||||
resetAggregateForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetAggregateForTests();
|
||||
delete process.env.TELEMETRY_DATA_DIR;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('POST /api/errors', () => {
|
||||
it('keeps nothing while error collection is off', async () => {
|
||||
const res = await POST(post(report));
|
||||
expect(res.status).toBe(204);
|
||||
await flushErrors();
|
||||
expect(await readErrors()).toEqual({});
|
||||
});
|
||||
|
||||
it('counts a report, scrubbed again, without its message', async () => {
|
||||
config.errorReportingDsn = DSN;
|
||||
expect((await POST(post(report))).status).toBe(204);
|
||||
await flushErrors();
|
||||
const stored = JSON.stringify(await readErrors());
|
||||
expect(stored).not.toContain('Merger');
|
||||
expect(stored).not.toContain('subject=');
|
||||
const [entry] = Object.values((await readErrors())[utcDay()]);
|
||||
expect(entry).toMatchObject({ name: 'TypeError', runtime: 'browser', version: '1.20.0', count: 1 });
|
||||
expect(entry.frames).toEqual([{ path: '/_next/static/chunks/1.js', line: 2, column: 30, fn: 'Ab' }]);
|
||||
});
|
||||
|
||||
it('counts while only the metrics endpoint is on, for the browser errors metric', async () => {
|
||||
process.env.METRICS_TOKEN = 'scrape-secret-0123456789';
|
||||
resetMetricsForTests();
|
||||
try {
|
||||
expect((await POST(post(report))).status).toBe(204);
|
||||
await flushErrors();
|
||||
expect(Object.keys((await readErrors())[utcDay()] ?? {})).toHaveLength(1);
|
||||
expect(await metrics().registry.metrics()).toContain('bulwark_browser_errors_total 1');
|
||||
} finally {
|
||||
delete process.env.METRICS_TOKEN;
|
||||
resetMetricsForTests();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores a name or code that is not an identifier or fixed code', async () => {
|
||||
config.errorReportingDsn = DSN;
|
||||
await POST(post({ ...report, name: SUBJECT, code: SUBJECT }));
|
||||
await flushErrors();
|
||||
const [entry] = Object.values((await readErrors())[utcDay()]);
|
||||
expect(entry.name).toBe('Error');
|
||||
expect(entry.code).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses bodies that are too large or not JSON objects', async () => {
|
||||
config.errorReportingDsn = DSN;
|
||||
expect((await POST(post('x'.repeat(17 * 1024)))).status).toBe(413);
|
||||
expect((await POST(post('[1,2]'))).status).toBe(400);
|
||||
expect((await POST(post('not json'))).status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getClientIP } from '@/lib/admin/session';
|
||||
import { getStore } from '@/lib/shared-store';
|
||||
import { recordError } from '@/lib/error-reporting/aggregate';
|
||||
import { appVersion, isErrorCode } from '@/lib/error-reporting/envelope';
|
||||
import { scrubName } from '@/lib/error-reporting/scrub';
|
||||
import { shouldCollectErrors } from '@/lib/error-reporting/settings';
|
||||
import { sanitizeFrames } from '@/lib/error-reporting/stack';
|
||||
import { countBrowserError } from '@/lib/metrics';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/errors: a browser of this instance reports an error it caught
|
||||
* (lib/error-reporting/client.ts), for the per-day error counts. Only while
|
||||
* collection is on (a DSN, the metrics endpoint or error-count consent);
|
||||
* otherwise 204 and nothing is kept.
|
||||
*
|
||||
* Same-origin only (the proxy's origin gate), at most 16 KB, 30 a minute per
|
||||
* address. Nothing a browser sends is trusted: every field is scrubbed again
|
||||
* and the fingerprint is computed here. The DSN gets the browser's own
|
||||
* report; this route only counts.
|
||||
*/
|
||||
|
||||
const MAX_BODY_BYTES = 16 * 1024;
|
||||
const PER_MINUTE = 30;
|
||||
|
||||
const NO_STORE = { 'Cache-Control': 'no-store' };
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!(await shouldCollectErrors())) return new NextResponse(null, { status: 204, headers: NO_STORE });
|
||||
|
||||
const length = Number(request.headers.get('content-length') ?? '0');
|
||||
if (length > MAX_BODY_BYTES) return NextResponse.json({ error: 'too large' }, { status: 413, headers: NO_STORE });
|
||||
|
||||
const limit = await getStore().counters.reserve([
|
||||
{ key: `error-reports:${getClientIP(request)}`, max: PER_MINUTE, windowMs: 60_000 },
|
||||
]).catch(() => ({ ok: true }));
|
||||
if (!limit.ok) return NextResponse.json({ error: 'rate limited' }, { status: 429, headers: NO_STORE });
|
||||
|
||||
let text: string;
|
||||
try {
|
||||
text = await request.text();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'bad request' }, { status: 400, headers: NO_STORE });
|
||||
}
|
||||
if (text.length > MAX_BODY_BYTES) return NextResponse.json({ error: 'too large' }, { status: 413, headers: NO_STORE });
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object');
|
||||
body = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'bad request' }, { status: 400, headers: NO_STORE });
|
||||
}
|
||||
|
||||
const code = isErrorCode(body.code) ? body.code : undefined;
|
||||
recordError(
|
||||
{
|
||||
name: scrubName(body.name),
|
||||
frames: sanitizeFrames(body.frames),
|
||||
...(code ? { code } : {}),
|
||||
},
|
||||
'browser',
|
||||
// The version the tab runs (a tab left open across an update runs the
|
||||
// old one, and its frames point into that); the server's when absent.
|
||||
typeof body.version === 'string' && /^\d+\.\d+\.\d+(?:[-+][\w.-]{1,40})?$/.test(body.version) ? body.version : appVersion(),
|
||||
);
|
||||
countBrowserError();
|
||||
return new NextResponse(null, { status: 204, headers: NO_STORE });
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Liveness: 200 while the process answers. Nothing else is checked, so a
|
||||
* Stalwart or store outage never makes an orchestrator restart Bulwark.
|
||||
* Readiness is /api/health/ready. https://bulwarkmail.org/docs/deployment/monitoring#health
|
||||
*/
|
||||
const HEADERS = { 'Cache-Control': 'no-store' };
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json({ status: 'ok' }, { headers: HEADERS });
|
||||
}
|
||||
|
||||
export function HEAD() {
|
||||
return new NextResponse(null, { status: 200, headers: HEADERS });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { readiness } from '@/lib/health/readiness';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Readiness: 200 when this replica can serve, 503 otherwise. Checks that
|
||||
* the config is loaded, the shared store (if any) answers, the data dirs are
|
||||
* writable and Stalwart's session resource answers within 2 s. The body
|
||||
* names the checks only. Results are reused for 5 s.
|
||||
* https://bulwarkmail.org/docs/deployment/monitoring#health
|
||||
*/
|
||||
const HEADERS = { 'Cache-Control': 'no-store' };
|
||||
|
||||
export async function GET() {
|
||||
const result = await readiness();
|
||||
return NextResponse.json(result, { status: result.ready ? 200 : 503, headers: HEADERS });
|
||||
}
|
||||
|
||||
export async function HEAD() {
|
||||
const result = await readiness();
|
||||
return new NextResponse(null, { status: result.ready ? 200 : 503, headers: HEADERS });
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { GET } from '../route';
|
||||
import { resetMetricsForTests } from '@/lib/metrics';
|
||||
|
||||
function scrape(authorization?: string): NextRequest {
|
||||
return new NextRequest('http://localhost/api/metrics', { headers: authorization ? { authorization } : {} });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.METRICS_TOKEN;
|
||||
delete process.env.METRICS_TOKEN_FILE;
|
||||
resetMetricsForTests();
|
||||
});
|
||||
|
||||
describe('GET /api/metrics', () => {
|
||||
it('does not exist without a token configured', async () => {
|
||||
const res = await GET(scrape('Bearer anything'));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('wants the bearer token', async () => {
|
||||
process.env.METRICS_TOKEN = 'scrape-secret-0123456789';
|
||||
expect((await GET(scrape())).status).toBe(401);
|
||||
expect((await GET(scrape('Bearer wrong'))).status).toBe(401);
|
||||
expect((await GET(scrape('Basic scrape-secret-0123456789'))).status).toBe(401);
|
||||
});
|
||||
|
||||
it('answers Prometheus text with the right token', async () => {
|
||||
process.env.METRICS_TOKEN = 'scrape-secret-0123456789';
|
||||
const res = await GET(scrape('Bearer scrape-secret-0123456789'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toContain('text/plain');
|
||||
const body = await res.text();
|
||||
expect(body).toContain('# TYPE bulwark_http_requests_total counter');
|
||||
expect(body).toContain('bulwark_build_info');
|
||||
expect(body).toContain('bulwark_nodejs_eventloop_lag_seconds');
|
||||
expect(body).toContain('bulwark_signins_total{outcome="rate_limited"} 0');
|
||||
});
|
||||
|
||||
it('reads the token from METRICS_TOKEN_FILE', async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'bulwark-metrics-'));
|
||||
try {
|
||||
writeFileSync(path.join(dir, 'token'), 'from-a-file-0123456789\n');
|
||||
process.env.METRICS_TOKEN_FILE = path.join(dir, 'token');
|
||||
expect((await GET(scrape('Bearer from-a-file-0123456789'))).status).toBe(200);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { metrics, metricsToken } from '@/lib/metrics';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/metrics: Prometheus metrics of this replica.
|
||||
*
|
||||
* Off by default: without METRICS_TOKEN (or METRICS_TOKEN_FILE) it answers
|
||||
* 404 as if it didn't exist. With one, the scraper sends
|
||||
* `Authorization: Bearer <token>`; anything else gets 401.
|
||||
*
|
||||
* https://bulwarkmail.org/docs/deployment/monitoring#metrics
|
||||
*/
|
||||
|
||||
function sameToken(given: string, expected: string): boolean {
|
||||
const a = Buffer.from(given);
|
||||
const b = Buffer.from(expected);
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = metricsToken();
|
||||
if (!token) return new NextResponse('Not Found', { status: 404, headers: { 'Cache-Control': 'no-store' } });
|
||||
|
||||
const header = request.headers.get('authorization') ?? '';
|
||||
const given = header.startsWith('Bearer ') ? header.slice('Bearer '.length).trim() : '';
|
||||
if (!given || !sameToken(given, token)) {
|
||||
return new NextResponse('Unauthorized', {
|
||||
status: 401,
|
||||
headers: { 'Cache-Control': 'no-store', 'WWW-Authenticate': 'Bearer realm="metrics"' },
|
||||
});
|
||||
}
|
||||
|
||||
const { registry } = metrics();
|
||||
return new NextResponse(await registry.metrics(), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': registry.contentType, 'Cache-Control': 'no-store' },
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { observeUpstream } from '@/lib/metrics/upstream';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { readFileEnv } from "@/lib/read-file-env";
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
@@ -174,10 +175,10 @@ async function mymemoryRequest(
|
||||
url.searchParams.set('q', q);
|
||||
url.searchParams.set('langpair', langpair);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
const res = await observeUpstream('translate', () => fetch(url.toString(), {
|
||||
signal,
|
||||
headers: { 'User-Agent': 'Bulwark-Webmail-Translate (+https://bulwarkmail.org)' },
|
||||
});
|
||||
}));
|
||||
const data = (await res.json().catch(() => null)) as
|
||||
| { responseStatus?: number | string; responseData?: { translatedText?: string }; responseDetails?: string }
|
||||
| null;
|
||||
@@ -232,7 +233,7 @@ async function translateLibre(
|
||||
process.env.LIBRETRANSLATE_API_KEY ||
|
||||
readFileEnv(process.env.LIBRETRANSLATE_API_KEY_FILE);
|
||||
const url = endpoint.replace(/\/+$/, '') + '/translate';
|
||||
const res = await fetch(url, {
|
||||
const res = await observeUpstream('translate', () => fetch(url, {
|
||||
method: 'POST',
|
||||
signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -243,7 +244,7 @@ async function translateLibre(
|
||||
format: 'text',
|
||||
...(apiKey ? { api_key: apiKey } : {}),
|
||||
}),
|
||||
});
|
||||
}));
|
||||
const data = (await res.json().catch(() => null)) as
|
||||
| { translatedText?: string; detectedLanguage?: { language?: string }; error?: string }
|
||||
| null;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { AlertTriangle, RefreshCw } from "@/components/icons";
|
||||
import { fetchConfig } from "@/hooks/use-config";
|
||||
import { captureException } from "@/lib/error-reporting/client";
|
||||
import "./globals.css";
|
||||
|
||||
/**
|
||||
@@ -25,6 +27,10 @@ export default function GlobalError({
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error("Global error:", error);
|
||||
// Waits for the config when the app crashed before it arrived; sent only
|
||||
// when error reporting is configured.
|
||||
captureException(error, "global-error", false);
|
||||
void fetchConfig().catch(() => undefined);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ArrowUpCircle,
|
||||
Activity,
|
||||
Bell,
|
||||
HeartRateMonitor,
|
||||
Building2,
|
||||
CalendarDays,
|
||||
ChevronRight,
|
||||
@@ -67,6 +68,7 @@ const PAGE_ICONS: Record<AdminPageId, AppIcon> = {
|
||||
marketplace: Store,
|
||||
version: ArrowUpCircle,
|
||||
telemetry: Activity,
|
||||
monitoring: HeartRateMonitor,
|
||||
logs: ScrollText,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { Component, ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { captureException } from "@/lib/error-reporting/client";
|
||||
|
||||
export interface FallbackProps {
|
||||
error: Error;
|
||||
@@ -43,6 +44,10 @@ class ErrorBoundaryCore extends Component<
|
||||
componentStack: errorInfo.componentStack,
|
||||
});
|
||||
|
||||
// Off unless error reporting is configured (lib/error-reporting/client.ts).
|
||||
// The component stack stays here: it names components, nothing else.
|
||||
captureException(error, "boundary", false);
|
||||
|
||||
// Call optional error handler
|
||||
this.props.onError?.(error, errorInfo);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { fetchConfig } from "@/hooks/use-config";
|
||||
import { installGlobalHandlers, recordNavigation } from "@/lib/error-reporting/client";
|
||||
|
||||
/**
|
||||
* Starts browser error reporting: window.onerror and unhandled rejections,
|
||||
* and a breadcrumb per route change (the route's area only, never the URL).
|
||||
* Whether anything is sent is decided by the config (/api/config, or Lite's
|
||||
* policy.json); until it arrives, reports wait, and with reporting off they
|
||||
* are dropped. Renders nothing.
|
||||
*/
|
||||
export function ErrorReportingRoot() {
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
installGlobalHandlers();
|
||||
// Cached after the first fetch; this only makes sure it happens on pages
|
||||
// that don't read the config themselves (admin, setup).
|
||||
void fetchConfig().catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname) recordNavigation(pathname);
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -124,6 +124,7 @@ export {
|
||||
IconH1 as Heading1,
|
||||
IconH2 as Heading2,
|
||||
IconHeart as Heart,
|
||||
IconHeartRateMonitor as HeartRateMonitor,
|
||||
IconHelpCircle as HelpCircle,
|
||||
IconHighlight as Highlighter,
|
||||
IconHome as Home,
|
||||
|
||||
@@ -30,6 +30,9 @@ DATA=/opt/bulwark-demo-data
|
||||
# exists only so pruning cannot collect the image we may need to go back to.
|
||||
PREVIOUS=bulwark-demo:previous
|
||||
STATE=/opt/bulwark-demo-deploy
|
||||
# Optional extra environment for the container (e.g. ERROR_REPORTING_DSN),
|
||||
# one VAR=value per line. Not in a bind-mounted dir: the app never sees it.
|
||||
ENV_FILE=/opt/bulwark-demo-data/demo.env
|
||||
|
||||
log() { echo "[demo-deploy] $*"; }
|
||||
|
||||
@@ -45,11 +48,14 @@ version_of() { docker_ image inspect -f '{{index .Config.Labels "org.opencontain
|
||||
# host port 3002, and the three bind mounts that keep the demo past the setup
|
||||
# wizard (admin/config.json holds setupComplete).
|
||||
run_container() {
|
||||
local env_args=()
|
||||
if [ -f "$ENV_FILE" ]; then env_args=(--env-file "$ENV_FILE"); fi
|
||||
docker_ rm -f "$NAME" >/dev/null 2>&1 || true
|
||||
docker_ run -d \
|
||||
--name "$NAME" \
|
||||
--restart unless-stopped \
|
||||
-p "${PORT}:3000" \
|
||||
${env_args[@]+"${env_args[@]}"} \
|
||||
-e DEMO_MODE=true \
|
||||
-v "$DATA/admin:/app/data/admin" \
|
||||
-v "$DATA/settings:/app/data/settings" \
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -8,6 +8,7 @@ import { IS_LITE, IS_LITE_STALWART, LITE_CONFIG_PATH, withLiteBuildId } from '@/
|
||||
import { applyLiteConfig, liteStalwartDefaults } from '@/lib/lite-config';
|
||||
import { isConfigData } from '@/lib/config-validation';
|
||||
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
|
||||
import { configureErrorReporting, type ClientSettings } from '@/lib/error-reporting/client';
|
||||
|
||||
export interface ConfigData {
|
||||
appName: string;
|
||||
@@ -59,6 +60,8 @@ export interface ConfigData {
|
||||
noThirdPartyTraffic: boolean;
|
||||
/** Lite only: whether sign-out revokes the refresh token (#1388). The server build decides in its token route. */
|
||||
oauthRevokeOnSignOut?: boolean;
|
||||
/** Server build only; Lite reads it from policy.json (stores/policy-store.ts). */
|
||||
errorReporting?: ClientSettings;
|
||||
}
|
||||
|
||||
interface AppConfig extends ConfigData {
|
||||
@@ -144,6 +147,8 @@ export async function fetchConfig(): Promise<ConfigData> {
|
||||
.then((data) => {
|
||||
configCache = data;
|
||||
setNoThirdPartyTraffic(data.noThirdPartyTraffic === true);
|
||||
// Lite configures error reporting from policy.json instead.
|
||||
if (!IS_LITE) configureErrorReporting(data.errorReporting ?? null);
|
||||
// Fetch admin policy alongside config (non-blocking)
|
||||
usePolicyStore.getState().fetchPolicy();
|
||||
return data;
|
||||
|
||||
+40
-20
@@ -9,12 +9,21 @@ import { bootState, getStore, sharedStoreUrl } from "./lib/shared-store";
|
||||
import { bootSharedMode } from "./lib/shared-store/boot";
|
||||
import { importLocalData } from "./lib/shared-store/import";
|
||||
import { exportSharedData } from "./lib/shared-store/export";
|
||||
import { logger, isJsonLogFormat } from "./lib/logger";
|
||||
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(`${process.cwd()}/package.json`, "utf-8")
|
||||
);
|
||||
const current: string = pkg.version ?? "0.0.0";
|
||||
console.info(`Bulwark Webmail v${current}`);
|
||||
logger.info(`Bulwark Webmail v${current}`, { version: current });
|
||||
|
||||
// Prometheus metrics, only with METRICS_TOKEN(_FILE): count every request
|
||||
// from the first one on. https://bulwarkmail.org/docs/deployment/monitoring#metrics
|
||||
if (process.env.METRICS_TOKEN?.trim() || process.env.METRICS_TOKEN_FILE?.trim()) {
|
||||
void import("./lib/metrics/http").then(({ installHttpMetrics }) => {
|
||||
if (installHttpMetrics()) logger.info("Metrics endpoint on at /api/metrics");
|
||||
});
|
||||
}
|
||||
|
||||
// With REDIS_URL every replica shares one store (#1066). Without it,
|
||||
// everything below runs exactly as it always has.
|
||||
@@ -36,12 +45,12 @@ async function exportAndExit(): Promise<never> {
|
||||
await store.ping(2000);
|
||||
break;
|
||||
} catch {
|
||||
console.warn("Export: waiting for the shared store");
|
||||
logger.warn("Export: waiting for the shared store");
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
const written = await exportSharedData();
|
||||
console.info("Export from the shared store done; start without REDIS_URL on this volume now.", written);
|
||||
logger.info("Export from the shared store done; start without REDIS_URL on this volume now.", { written });
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -57,18 +66,23 @@ migrateLegacyAdminLayout()
|
||||
.then(() => migratePolicyUnifiedMailbox())
|
||||
.then(() => (exportOnly ? exportAndExit() : shared ? bootShared() : loadAdmin()))
|
||||
.then(async () => {
|
||||
console.info("Admin dashboard initialized");
|
||||
logger.info("Admin dashboard initialized");
|
||||
// Errors routes catch and log, and errors Next reports (instrumentation.ts),
|
||||
// go to the error tracker when one is configured. Off otherwise.
|
||||
// https://bulwarkmail.org/docs/deployment/monitoring
|
||||
const { installServerErrorReporting } = await import("./lib/error-reporting/server");
|
||||
await installServerErrorReporting();
|
||||
// With a shared store, boot already made or loaded the secret.
|
||||
if (!shared) {
|
||||
try {
|
||||
if (await ensureSessionSecret()) {
|
||||
console.info(
|
||||
logger.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);
|
||||
logger.warn("Failed to generate a session secret", { error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
// If we're in bootstrap state (no JMAP_SERVER_URL env and no
|
||||
@@ -79,20 +93,22 @@ migrateLegacyAdminLayout()
|
||||
try {
|
||||
const token = await ensureSetupToken();
|
||||
const port = process.env.PORT || "3000";
|
||||
console.info("");
|
||||
console.info("==============================================================");
|
||||
console.info(" SETUP REQUIRED");
|
||||
console.info(` Token: ${token}`);
|
||||
// In the fragment, so the token stays out of access logs and Referer.
|
||||
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("");
|
||||
const open = `http://<host>:${port}/setup#token=${token}`;
|
||||
if (isJsonLogFormat()) {
|
||||
logger.info("Setup required", { token, open, expiresIn: "1h" });
|
||||
} else {
|
||||
for (const line of [
|
||||
"==============================================================",
|
||||
" SETUP REQUIRED",
|
||||
` Token: ${token}`,
|
||||
` Open: ${open}`,
|
||||
" Token expires in 1 hour. Restart the container to reissue.",
|
||||
"==============================================================",
|
||||
]) logger.info(line);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"Failed to issue setup token:",
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
logger.warn("Failed to issue setup token", { error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -103,18 +119,22 @@ migrateLegacyAdminLayout()
|
||||
// See https://bulwarkmail.org/docs/legal/privacy/telemetry
|
||||
const { startScheduler, stopScheduler, markProcessStart } = await import("./lib/telemetry");
|
||||
markProcessStart();
|
||||
// Instant error counts: a one-minute tick that sends only while the
|
||||
// error-count consent is "instant".
|
||||
const instantErrors = await import("./lib/telemetry/instant-errors");
|
||||
// Hourly check against version.telemetry.bulwarkmail.org. Disable with
|
||||
// BULWARK_UPDATE_CHECK=off or override the endpoint with
|
||||
// BULWARK_UPDATE_CHECK_URL.
|
||||
const versionCheck = await import("./lib/version-check");
|
||||
// Both run on one replica of the deployment, the scheduler leader
|
||||
// All run on one replica of the deployment, the scheduler leader
|
||||
// (#1066); a single instance always leads.
|
||||
const { startSchedulerLeader } = await import("./lib/scheduler-leader");
|
||||
await startSchedulerLeader([
|
||||
{ name: "telemetry", start: startScheduler, stop: stopScheduler },
|
||||
{ name: "telemetry-errors", start: instantErrors.startInstantErrors, stop: instantErrors.stopInstantErrors },
|
||||
{ name: "version-check", start: versionCheck.startScheduler, stop: versionCheck.stopScheduler },
|
||||
]);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
|
||||
logger.warn("Admin dashboard init skipped", { error: err instanceof Error ? err.message : String(err) });
|
||||
});
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
import type { Instrumentation } from "next";
|
||||
import { tracingConfigured } from "./lib/tracing/config";
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
// OpenTelemetry, only with an OTLP endpoint in the standard OTEL_*
|
||||
// variables; otherwise the SDK is never loaded. First, so Next's own
|
||||
// spans from the first request on go to it.
|
||||
// https://bulwarkmail.org/docs/deployment/monitoring#tracing
|
||||
if (tracingConfigured()) {
|
||||
const { startTracing } = await import("./lib/tracing/start");
|
||||
startTracing();
|
||||
}
|
||||
await import("./instrumentation.node");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Errors Next caught while rendering a page or running a route handler,
|
||||
* server action or the proxy. Counted, and sent to the error tracker when
|
||||
* one is configured (lib/error-reporting/server.ts); nothing happens while
|
||||
* error reporting is off. https://bulwarkmail.org/docs/deployment/monitoring
|
||||
*/
|
||||
export const onRequestError: Instrumentation.onRequestError = async (error, _request, context) => {
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
const { captureRequestError } = await import("./lib/error-reporting/server");
|
||||
await captureRequestError(error, context.routePath);
|
||||
};
|
||||
|
||||
@@ -107,3 +107,18 @@ describe('server configuration validation', () => {
|
||||
expect(isConfigData({ ...createConfig(), jmapServers: [entry] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error reporting settings in the server configuration', () => {
|
||||
const settings = { dsn: '', sampleRate: 1, environment: 'production', collect: false };
|
||||
|
||||
it('are optional, for Lite and servers before they existed', () => {
|
||||
expect(isConfigData({ ...createConfig(), errorReporting: settings })).toBe(true);
|
||||
expect(isConfigData(createConfig())).toBe(true);
|
||||
});
|
||||
|
||||
it('are refused when malformed', () => {
|
||||
expect(isConfigData({ ...createConfig(), errorReporting: { ...settings, sampleRate: '1' } })).toBe(false);
|
||||
expect(isConfigData({ ...createConfig(), errorReporting: { ...settings, collect: 'yes' } })).toBe(false);
|
||||
expect(isConfigData({ ...createConfig(), errorReporting: 'https://key@x.example/1' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const reported = vi.hoisted(() => ({ calls: [] as string[][], failures: [] as unknown[] }));
|
||||
vi.mock('@/lib/error-reporting/client', () => ({
|
||||
recordJmapCall: (methods: string[]) => { reported.calls.push(methods); },
|
||||
reportJmapFailure: (failure: unknown) => { reported.failures.push(failure); },
|
||||
}));
|
||||
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
|
||||
type Call = [string, Record<string, unknown>, string];
|
||||
const SUBJECT = 'Merger plans for Acme Q3';
|
||||
|
||||
function clientAnswering(response: Response) {
|
||||
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
|
||||
Object.assign(client, { apiUrl: 'https://jmap.example.com/jmap/', accountId: 'me' });
|
||||
vi.spyOn(client as unknown as { authenticatedFetch: () => Promise<Response> }, 'authenticatedFetch').mockResolvedValue(response);
|
||||
const request = (calls: Call[]) => (client as unknown as { request: (c: Call[]) => Promise<unknown> }).request(calls);
|
||||
return request;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reported.calls = [];
|
||||
reported.failures = [];
|
||||
});
|
||||
|
||||
describe('JMAP requests and error reporting', () => {
|
||||
it('keeps the method names of each request as a breadcrumb', async () => {
|
||||
const request = clientAnswering(new Response(JSON.stringify({ methodResponses: [] })));
|
||||
await request([['Email/query', { filter: { subject: SUBJECT } }, '0'], ['Email/get', {}, '1']]);
|
||||
expect(reported.calls).toEqual([['Email/query', 'Email/get']]);
|
||||
expect(reported.failures).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports a failed request by its status only', async () => {
|
||||
const request = clientAnswering(new Response(`{"detail":"${SUBJECT}"}`, { status: 503 }));
|
||||
await expect(request([['Email/get', {}, '0']])).rejects.toThrow();
|
||||
expect(reported.failures).toEqual([{ status: 503 }]);
|
||||
});
|
||||
|
||||
it('does not report an expired token or the concurrency limit', async () => {
|
||||
const request = clientAnswering(new Response('', { status: 401 }));
|
||||
await expect(request([['Email/get', {}, '0']])).rejects.toThrow();
|
||||
expect(reported.failures).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports method errors by method and type, but not the ones that only mean "sync again"', async () => {
|
||||
const request = clientAnswering(new Response(JSON.stringify({
|
||||
methodResponses: [
|
||||
['error', { type: 'overQuota', description: SUBJECT }, 'a'],
|
||||
['error', { type: 'cannotCalculateChanges' }, 'b'],
|
||||
['Mailbox/get', { list: [] }, 'c'],
|
||||
],
|
||||
})));
|
||||
await request([['Email/set', {}, 'a'], ['Email/changes', {}, 'b'], ['Mailbox/get', {}, 'c']]);
|
||||
expect(reported.failures).toEqual([{ method: 'Email/set', type: 'overQuota' }]);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,10 @@ beforeEach(() => {
|
||||
subject: 'Away', textBody: 'Back in June', htmlBody: null,
|
||||
})),
|
||||
setVacationResponse: vi.fn(async () => undefined),
|
||||
// The out-of-office calendar sync (lib/vacation-calendar.ts) reads the
|
||||
// account and skips servers without calendars.
|
||||
getAccountId: vi.fn(() => 'acc1'),
|
||||
supportsCalendars: vi.fn(() => false),
|
||||
};
|
||||
useAuthStore.setState({ client: client as unknown as IJMAPClient });
|
||||
useTemplateStore.setState({ templates: [greeting], recentTemplateIds: [], deletedTemplateIds: {} });
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// @vitest-environment node
|
||||
import { readdirSync, statSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { API_GROUPS, PAGE_AREAS } from '@/lib/route-groups';
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
const dirs = (rel: string) => readdirSync(path.join(ROOT, rel)).filter((name) => statSync(path.join(ROOT, rel, name)).isDirectory());
|
||||
|
||||
// The groups are a fixed list so metric labels and report routes stay
|
||||
// bounded; a new API folder or page area has to be added to it.
|
||||
describe('route groups', () => {
|
||||
it('list every API folder', () => {
|
||||
expect([...API_GROUPS].sort()).toEqual(dirs('app/api').filter((d) => d !== '__tests__').sort());
|
||||
});
|
||||
|
||||
it('list every page area', () => {
|
||||
const areas = dirs('app/(main)/[locale]').filter((d) => !d.startsWith('[') && !d.startsWith('('));
|
||||
for (const area of areas) expect(PAGE_AREAS).toContain(area);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
// @vitest-environment node
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { check, collect, mapReferences } from '../../scripts/sourcemaps.mjs';
|
||||
import { buildZip } from '../../scripts/lite/lib.mjs';
|
||||
|
||||
// CI builds source maps for the error tracker; none may ship (images, the
|
||||
// standalone tarball, Lite zips).
|
||||
|
||||
let dir: string;
|
||||
const put = (rel: string, text: string) => {
|
||||
const file = path.join(dir, rel);
|
||||
mkdirSync(path.dirname(file), { recursive: true });
|
||||
writeFileSync(file, text);
|
||||
};
|
||||
|
||||
beforeEach(() => { dir = mkdtempSync(path.join(tmpdir(), 'bulwark-maps-')); });
|
||||
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
function tar(files: Record<string, string>): Buffer {
|
||||
const blocks: Buffer[] = [];
|
||||
for (const [name, text] of Object.entries(files)) {
|
||||
const data = Buffer.from(text);
|
||||
const header = Buffer.alloc(512);
|
||||
header.write(name, 0);
|
||||
header.write('0000644\0', 100);
|
||||
header.write(data.length.toString(8).padStart(11, '0') + '\0', 124);
|
||||
header.write('0', 156);
|
||||
header.write('ustar\0', 257);
|
||||
blocks.push(header, data, Buffer.alloc((512 - (data.length % 512)) % 512));
|
||||
}
|
||||
blocks.push(Buffer.alloc(1024));
|
||||
return Buffer.concat(blocks);
|
||||
}
|
||||
|
||||
describe('collect', () => {
|
||||
it('moves maps out with the chunks that name them, and strips the comment from what ships', () => {
|
||||
put('static/chunks/a.js', 'console.log(1);\n//# debugId=bc67f7db-7f3b-7be2-6f94-966cd7825cc2\n//# sourceMappingURL=a1b2.js.map\n');
|
||||
put('static/chunks/a1b2.js.map', '{"version":3}');
|
||||
put('static/css/x.css', 'a{}\n/*# sourceMappingURL=x.css.map */\n');
|
||||
put('static/css/x.css.map', '{"version":3}');
|
||||
put('static/chunks/orphan.js.map', '{"version":3}');
|
||||
const out = path.join(dir, 'maps');
|
||||
|
||||
const moved = collect([path.join(dir, 'static')], out);
|
||||
|
||||
expect(moved).toHaveLength(3);
|
||||
expect(existsSync(path.join(dir, 'static/chunks/a1b2.js.map'))).toBe(false);
|
||||
const shipped = readFileSync(path.join(dir, 'static/chunks/a.js'), 'utf8');
|
||||
expect(mapReferences(shipped)).toEqual([]);
|
||||
expect(shipped).toContain('//# debugId=bc67f7db-7f3b-7be2-6f94-966cd7825cc2');
|
||||
expect(readFileSync(path.join(out, '0/chunks/a.js'), 'utf8')).toContain('sourceMappingURL=a1b2.js.map');
|
||||
expect(existsSync(path.join(out, '0/chunks/a1b2.js.map'))).toBe(true);
|
||||
expect(existsSync(path.join(out, '0/chunks/orphan.js.map'))).toBe(true);
|
||||
expect(check(path.join(dir, 'static'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collect with a relative root', () => {
|
||||
it('still copies the chunk that names each map', () => {
|
||||
put('rel/chunks/b.js', 'x\n//# sourceMappingURL=b.js.map\n');
|
||||
put('rel/chunks/b.js.map', '{"version":3}');
|
||||
const cwd = process.cwd();
|
||||
process.chdir(dir);
|
||||
try {
|
||||
collect(['rel'], 'out');
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
}
|
||||
expect(existsSync(path.join(dir, 'out/0/chunks/b.js'))).toBe(true);
|
||||
expect(existsSync(path.join(dir, 'out/0/chunks/b.js.map'))).toBe(true);
|
||||
expect(mapReferences(readFileSync(path.join(dir, 'rel/chunks/b.js'), 'utf8'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('check', () => {
|
||||
it('finds map files, dangling references and inline maps in a folder', () => {
|
||||
put('a/x.js.map', '{}');
|
||||
put('a/y.js', 'x\n//# sourceMappingURL=gone.js.map\n');
|
||||
put('a/z.js', 'x\n//# sourceMappingURL=data:application/json;base64,e30=\n');
|
||||
put('a/ok.js', 'const s = "//# sourceMappingURL=in-a-string.map";\n');
|
||||
expect(check(path.join(dir, 'a')).sort()).toEqual([
|
||||
'x.js.map: source map file',
|
||||
'y.js: sourceMappingURL to missing gone.js.map',
|
||||
'z.js: inline source map',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads zip archives', () => {
|
||||
const zip = path.join(dir, 'lite.zip');
|
||||
writeFileSync(zip, buildZip([
|
||||
{ name: '_next/static/chunks/a.js', data: Buffer.from('x\n//# sourceMappingURL=a.js.map\n') },
|
||||
{ name: '_next/static/chunks/a.js.map', data: Buffer.from('{}') },
|
||||
]));
|
||||
expect(check(zip)).toEqual(['_next/static/chunks/a.js.map: source map file']);
|
||||
writeFileSync(zip, buildZip([{ name: 'index.html', data: Buffer.from('<html>') }]));
|
||||
expect(check(zip)).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads gzipped tarballs', () => {
|
||||
const tgz = path.join(dir, 'standalone.tar.gz');
|
||||
writeFileSync(tgz, gzipSync(tar({
|
||||
'bulwark-standalone/.next/server/chunks/1.js': 'x\n//# sourceMappingURL=1.js.map\n',
|
||||
'bulwark-standalone/.next/server/chunks/1.js.map': '{}',
|
||||
})));
|
||||
expect(check(tgz)).toEqual(['bulwark-standalone/.next/server/chunks/1.js.map: source map file']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
// @vitest-environment node
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// Error counts in the heartbeat (schema 2) have their own consent, off by
|
||||
// default and off whenever telemetry is off. Without it the heartbeat is
|
||||
// exactly schema 1.
|
||||
|
||||
const thirdParty = vi.hoisted(() => ({ on: false }));
|
||||
vi.mock('@/lib/admin/third-party-mode', () => ({ isNoThirdPartyTrafficOn: async () => thirdParty.on }));
|
||||
vi.mock('@/lib/admin/session', () => ({
|
||||
requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })),
|
||||
getClientIP: () => '127.0.0.1',
|
||||
}));
|
||||
vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn(async () => {}) }));
|
||||
vi.mock('@/lib/admin/plugin-registry', () => ({
|
||||
getPluginRegistry: async () => ({ plugins: [] }),
|
||||
getThemeRegistry: async () => ({ themes: [] }),
|
||||
}));
|
||||
|
||||
import { GET, POST } from '@/app/api/admin/telemetry/route';
|
||||
import { buildPayload, effectiveErrorsConsent, errorsForPayload, loadState, saveState } from '@/lib/telemetry';
|
||||
import { resetAggregateForTests, utcDay, type Aggregate } from '@/lib/error-reporting/aggregate';
|
||||
|
||||
let dir: string;
|
||||
const DAY = 86_400_000;
|
||||
const NOW = Date.parse('2026-11-10T12:00:00Z');
|
||||
const debugId = 'bc67f7db-7f3b-7be2-6f94-966cd7825cc2';
|
||||
|
||||
function post(body: Record<string, unknown>) {
|
||||
return POST(new NextRequest('http://localhost/api/admin/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}));
|
||||
}
|
||||
|
||||
function entry(count: number, extra: Partial<Aggregate[string][string]> = {}) {
|
||||
return {
|
||||
name: 'TypeError',
|
||||
frames: [
|
||||
{ path: '/_next/static/chunks/1.js', line: 2, column: 30, debugId },
|
||||
{ path: 'node_modules/next/dist/x.js', line: 1, column: 1 },
|
||||
{ path: '[other]' },
|
||||
],
|
||||
version: '1.20.0',
|
||||
runtime: 'browser' as const,
|
||||
count,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), 'bulwark-telemetry-errors-'));
|
||||
vi.stubEnv('ADMIN_CONFIG_DIR', dir);
|
||||
vi.stubEnv('TELEMETRY_DATA_DIR', path.join(dir, 'telemetry'));
|
||||
});
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
beforeEach(async () => {
|
||||
thirdParty.on = false;
|
||||
vi.stubEnv('BULWARK_TELEMETRY', '');
|
||||
vi.stubEnv('BULWARK_TELEMETRY_ERRORS', '');
|
||||
resetAggregateForTests();
|
||||
await saveState({ ...(await loadState()), consent: 'on', errorsConsent: 'off' });
|
||||
const yesterday = utcDay(Date.now() - DAY);
|
||||
mkdirSync(path.join(dir, 'telemetry'), { recursive: true });
|
||||
writeFileSync(path.join(dir, 'telemetry', 'errors.json'), JSON.stringify({ [yesterday]: { aaaa000011112222: entry(7) } }));
|
||||
});
|
||||
afterEach(() => {
|
||||
resetAggregateForTests();
|
||||
});
|
||||
|
||||
describe('error counts consent', () => {
|
||||
it('is off by default and the heartbeat stays schema 1, without errors', async () => {
|
||||
expect((await effectiveErrorsConsent()).consent).toBe('off');
|
||||
const payload = await buildPayload();
|
||||
expect(payload.schema).toBe('1');
|
||||
expect('errors' in payload).toBe(false);
|
||||
});
|
||||
|
||||
it('turns the heartbeat into schema 2 with the counts when on', async () => {
|
||||
expect((await post({ action: 'set-errors-consent', consent: 'on' })).status).toBe(200);
|
||||
const payload = await buildPayload();
|
||||
expect(payload.schema).toBe('2');
|
||||
expect(payload.schema === '2' && payload.errors).toEqual([{
|
||||
name: 'TypeError',
|
||||
fingerprint: 'aaaa000011112222',
|
||||
frames: [{ path: '/_next/static/chunks/1.js', line: 2, col: 30, debug_id: debugId }],
|
||||
version: '1.20.0',
|
||||
count: '6-10',
|
||||
days: 1,
|
||||
}]);
|
||||
});
|
||||
|
||||
it('can\'t be turned on while telemetry is off, and goes off with it', async () => {
|
||||
await saveState({ ...(await loadState()), consent: 'off' });
|
||||
expect((await post({ action: 'set-errors-consent', consent: 'on' })).status).toBe(409);
|
||||
|
||||
await saveState({ ...(await loadState()), consent: 'on', errorsConsent: 'on' });
|
||||
expect((await effectiveErrorsConsent()).consent).toBe('on');
|
||||
await post({ action: 'set-consent', consent: 'off' });
|
||||
expect((await loadState()).errorsConsent).toBe('off');
|
||||
await post({ action: 'set-consent', consent: 'on' });
|
||||
expect((await effectiveErrorsConsent()).consent).toBe('off');
|
||||
});
|
||||
|
||||
it('is off in no-third-party mode, whatever is stored or set in the env', async () => {
|
||||
await saveState({ ...(await loadState()), errorsConsent: 'on' });
|
||||
vi.stubEnv('BULWARK_TELEMETRY_ERRORS', 'on');
|
||||
thirdParty.on = true;
|
||||
expect(await effectiveErrorsConsent()).toEqual({ consent: 'off', source: 'no-third-party' });
|
||||
});
|
||||
|
||||
it('follows BULWARK_TELEMETRY_ERRORS and refuses changes from the panel then', async () => {
|
||||
vi.stubEnv('BULWARK_TELEMETRY_ERRORS', 'on');
|
||||
expect(await effectiveErrorsConsent()).toEqual({ consent: 'on', source: 'env' });
|
||||
expect((await post({ action: 'set-errors-consent', consent: 'off' })).status).toBe(409);
|
||||
});
|
||||
|
||||
it('shows the v2 errors in the admin preview before anything is turned on', async () => {
|
||||
const body = await (await GET(new NextRequest('http://localhost/api/admin/telemetry'))).json();
|
||||
expect(body.payloadPreview.schema).toBe('1');
|
||||
expect(body.errorsConsent).toBe('off');
|
||||
expect(body.errorsPreview).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errors in the payload', () => {
|
||||
const yesterday = utcDay(NOW - DAY);
|
||||
|
||||
it('come from the last complete day, most frequent first, at most 50', () => {
|
||||
const day: Aggregate[string] = {};
|
||||
for (let i = 0; i < 60; i++) day[`f${String(i).padStart(15, '0')}`] = entry(i + 1);
|
||||
const errors = errorsForPayload({ [yesterday]: day, [utcDay(NOW)]: { today: entry(999) } }, NOW);
|
||||
expect(errors).toHaveLength(50);
|
||||
expect(errors[0].fingerprint).toBe('f000000000000059');
|
||||
expect(errors.some((e) => e.fingerprint === 'today')).toBe(false);
|
||||
});
|
||||
|
||||
it('count the days of the last week a fingerprint occurred on', () => {
|
||||
const agg: Aggregate = {
|
||||
[yesterday]: { f: entry(1) },
|
||||
[utcDay(NOW - 3 * DAY)]: { f: entry(1) },
|
||||
[utcDay(NOW - 9 * DAY)]: { f: entry(1) },
|
||||
};
|
||||
expect(errorsForPayload(agg, NOW)[0].days).toBe(2);
|
||||
});
|
||||
|
||||
it('carry a message only when it is a fixed error code', () => {
|
||||
const agg: Aggregate = {
|
||||
[yesterday]: {
|
||||
a: entry(1, { name: 'JmapMethodError', code: 'Email/set: overQuota' }),
|
||||
b: entry(1, { code: 'Merger plans for Acme' as string }),
|
||||
},
|
||||
};
|
||||
const [withCode, without] = errorsForPayload(agg, NOW);
|
||||
expect(withCode.message).toBe('Email/set: overQuota');
|
||||
expect(without.message).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
// @vitest-environment node
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// Instant error counts: with the error-count consent set to "instant", the
|
||||
// scheduler leader sends the current day's counts to the collector's
|
||||
// /v1/errors once a minute, but only errors that are new or whose count
|
||||
// bucket changed since the last send. Daily mode sends nothing extra.
|
||||
|
||||
const thirdParty = vi.hoisted(() => ({ on: false }));
|
||||
vi.mock('@/lib/admin/third-party-mode', () => ({ isNoThirdPartyTrafficOn: async () => thirdParty.on }));
|
||||
vi.mock('@/lib/admin/session', () => ({
|
||||
requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })),
|
||||
getClientIP: () => '127.0.0.1',
|
||||
}));
|
||||
vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn(async () => {}) }));
|
||||
vi.mock('@/lib/admin/plugin-registry', () => ({
|
||||
getPluginRegistry: async () => ({ plugins: [] }),
|
||||
getThemeRegistry: async () => ({ themes: [] }),
|
||||
}));
|
||||
|
||||
const sent = vi.fn(async (_url: string, _init?: { body?: string }) => new Response('{"ok":true}'));
|
||||
vi.mock('@/lib/telemetry/endpoint-guard', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/telemetry/endpoint-guard')>();
|
||||
return {
|
||||
...actual,
|
||||
resolveEndpointAllowed: vi.fn(async () => ({ ok: true })),
|
||||
fetchTelemetryTarget: (url: string, init?: { body?: string }) => sent(url, init),
|
||||
};
|
||||
});
|
||||
|
||||
import { POST } from '@/app/api/admin/telemetry/route';
|
||||
import { DEFAULT_ENDPOINT, buildPayload, effectiveErrorsConsent, loadState, saveState } from '@/lib/telemetry';
|
||||
import { resetInstantErrorsForTests, sendInstantErrors } from '@/lib/telemetry/instant-errors';
|
||||
import { resetAggregateForTests, utcDay } from '@/lib/error-reporting/aggregate';
|
||||
|
||||
let dir: string;
|
||||
const debugId = 'bc67f7db-7f3b-7be2-6f94-966cd7825cc2';
|
||||
|
||||
function entry(count: number, name = 'TypeError') {
|
||||
return {
|
||||
name,
|
||||
frames: [
|
||||
{ path: '/_next/static/chunks/1.js', line: 2, column: 30, debugId },
|
||||
{ path: 'node_modules/next/dist/x.js', line: 1, column: 1 },
|
||||
],
|
||||
version: '1.20.0',
|
||||
runtime: 'browser' as const,
|
||||
count,
|
||||
};
|
||||
}
|
||||
|
||||
function storeCounts(day: Record<string, ReturnType<typeof entry>>): void {
|
||||
writeFileSync(path.join(dir, 'telemetry', 'errors.json'), JSON.stringify({ [utcDay()]: day }));
|
||||
}
|
||||
|
||||
function bodies(): Array<Record<string, unknown> & { errors: Array<Record<string, unknown>> }> {
|
||||
return sent.mock.calls.map(([, init]) => JSON.parse(String(init?.body)));
|
||||
}
|
||||
|
||||
function post(body: Record<string, unknown>) {
|
||||
return POST(new NextRequest('http://localhost/api/admin/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}));
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), 'bulwark-telemetry-instant-'));
|
||||
vi.stubEnv('ADMIN_CONFIG_DIR', dir);
|
||||
vi.stubEnv('TELEMETRY_DATA_DIR', path.join(dir, 'telemetry'));
|
||||
mkdirSync(path.join(dir, 'telemetry'), { recursive: true });
|
||||
});
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
beforeEach(async () => {
|
||||
thirdParty.on = false;
|
||||
vi.stubEnv('BULWARK_TELEMETRY', '');
|
||||
vi.stubEnv('BULWARK_TELEMETRY_ERRORS', '');
|
||||
sent.mockClear();
|
||||
sent.mockImplementation(async () => new Response('{"ok":true}'));
|
||||
resetAggregateForTests();
|
||||
resetInstantErrorsForTests();
|
||||
await saveState({ ...(await loadState()), consent: 'on', endpoint: DEFAULT_ENDPOINT, errorsConsent: 'instant' });
|
||||
storeCounts({ aaaa000011112222: entry(1) });
|
||||
});
|
||||
afterEach(() => {
|
||||
resetAggregateForTests();
|
||||
resetInstantErrorsForTests();
|
||||
});
|
||||
|
||||
describe('instant error counts', () => {
|
||||
it('sends today\'s counts to the collector\'s /v1/errors, nothing but the allowed fields', async () => {
|
||||
await sendInstantErrors();
|
||||
expect(sent).toHaveBeenCalledTimes(1);
|
||||
expect(sent.mock.calls[0][0]).toBe('https://telemetry.bulwarkmail.org/v1/errors');
|
||||
const [body] = bodies();
|
||||
expect(Object.keys(body).sort()).toEqual(['day', 'errors', 'instance_id', 'schema']);
|
||||
expect(body.schema).toBe('1');
|
||||
expect(body.day).toBe(utcDay());
|
||||
expect(body.instance_id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(body.errors).toEqual([{
|
||||
name: 'TypeError',
|
||||
fingerprint: 'aaaa000011112222',
|
||||
frames: [{ path: '/_next/static/chunks/1.js', line: 2, col: 30, debug_id: debugId }],
|
||||
version: '1.20.0',
|
||||
count: '1',
|
||||
days: 1,
|
||||
}]);
|
||||
});
|
||||
|
||||
it('sends an error again only when its count bucket changes', async () => {
|
||||
storeCounts({ aaaa000011112222: entry(1), bbbb000011112222: entry(1, 'RangeError') });
|
||||
await sendInstantErrors();
|
||||
expect(bodies()[0].errors).toHaveLength(2);
|
||||
|
||||
await sendInstantErrors();
|
||||
expect(sent).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 1 -> 3 moves from '1' to '2-5'.
|
||||
storeCounts({ aaaa000011112222: entry(3), bbbb000011112222: entry(1, 'RangeError') });
|
||||
await sendInstantErrors();
|
||||
expect(sent).toHaveBeenCalledTimes(2);
|
||||
expect(bodies()[1].errors.map((e) => [e.fingerprint, e.count])).toEqual([['aaaa000011112222', '2-5']]);
|
||||
|
||||
// 3 -> 4 is still '2-5': nothing new to say.
|
||||
storeCounts({ aaaa000011112222: entry(4), bbbb000011112222: entry(1, 'RangeError') });
|
||||
await sendInstantErrors();
|
||||
expect(sent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('tries a failed send again on the next tick', async () => {
|
||||
sent.mockImplementationOnce(async () => new Response('{}', { status: 503 }));
|
||||
await sendInstantErrors();
|
||||
await sendInstantErrors();
|
||||
expect(sent).toHaveBeenCalledTimes(2);
|
||||
expect(bodies()[1].errors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('sends nothing in daily mode, with error counts off, with the heartbeat off or in no-third-party mode', async () => {
|
||||
await saveState({ ...(await loadState()), errorsConsent: 'on' });
|
||||
await sendInstantErrors();
|
||||
await saveState({ ...(await loadState()), errorsConsent: 'off' });
|
||||
await sendInstantErrors();
|
||||
await saveState({ ...(await loadState()), consent: 'off', errorsConsent: 'instant' });
|
||||
await sendInstantErrors();
|
||||
await saveState({ ...(await loadState()), consent: 'on' });
|
||||
thirdParty.on = true;
|
||||
await sendInstantErrors();
|
||||
expect(sent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends nothing when the collector URL is empty, and follows a custom one', async () => {
|
||||
await saveState({ ...(await loadState()), endpoint: '' });
|
||||
await sendInstantErrors();
|
||||
expect(sent).not.toHaveBeenCalled();
|
||||
await saveState({ ...(await loadState()), endpoint: 'https://collector.example.org/v1/heartbeat' });
|
||||
await sendInstantErrors();
|
||||
expect(sent.mock.calls[0][0]).toBe('https://collector.example.org/v1/errors');
|
||||
});
|
||||
|
||||
it('also sends the daily heartbeat with error counts (schema 2)', async () => {
|
||||
expect((await buildPayload()).schema).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('instant consent', () => {
|
||||
it('can be chosen in the admin panel and needs the heartbeat', async () => {
|
||||
await saveState({ ...(await loadState()), errorsConsent: 'off' });
|
||||
expect((await post({ action: 'set-errors-consent', consent: 'instant' })).status).toBe(200);
|
||||
expect((await loadState()).errorsConsent).toBe('instant');
|
||||
expect((await effectiveErrorsConsent()).consent).toBe('instant');
|
||||
|
||||
await saveState({ ...(await loadState()), consent: 'off', errorsConsent: 'off' });
|
||||
expect((await post({ action: 'set-errors-consent', consent: 'instant' })).status).toBe(409);
|
||||
});
|
||||
|
||||
it('can be set with BULWARK_TELEMETRY_ERRORS=instant', async () => {
|
||||
await saveState({ ...(await loadState()), errorsConsent: 'off' });
|
||||
vi.stubEnv('BULWARK_TELEMETRY_ERRORS', 'instant');
|
||||
expect(await effectiveErrorsConsent()).toEqual({ consent: 'instant', source: 'env' });
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,10 @@ vi.mock('@/lib/admin/config-manager', () => ({
|
||||
get: (key: string, fallback: unknown) => (key === 'jmapServerUrl' ? 'https://mail.example.org' : fallback),
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/telemetry/state', () => ({ getInstanceId: async () => 'instance-1' }));
|
||||
vi.mock('@/lib/telemetry/state', () => ({
|
||||
getInstanceId: async () => 'instance-1',
|
||||
effectiveErrorsConsent: async () => ({ consent: 'off', source: 'file' }),
|
||||
}));
|
||||
vi.mock('@/lib/telemetry/login-tracker', () => ({ getLoginCounts: async () => ({ total: 3, active7d: 1 }) }));
|
||||
vi.mock('@/lib/admin/plugin-registry', () => ({
|
||||
getPluginRegistry: async () => ({ plugins: [] }),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { listReachableHosts, noThirdPartyCspSources } from '@/lib/admin/reachable-hosts';
|
||||
|
||||
const policy = { pushRelays: [], pushRelayUrl: '', defaultSidebarApps: [], features: { sidebarAppsEnabled: false, calendarEnabled: false } };
|
||||
|
||||
function hosts(config: Record<string, unknown>, env: Record<string, string | undefined> = {}) {
|
||||
return listReachableHosts({ get: (key) => config[key], policy, env, buildRelayUrl: '' });
|
||||
}
|
||||
|
||||
// No third-party traffic (#903) still allows the hosts the admin set: the
|
||||
// error tracker is one of them, and the page's CSP must let browsers reach it.
|
||||
describe('error reporting in the no-third-party list', () => {
|
||||
it('is listed, without the key, only when a DSN is set', () => {
|
||||
expect(hosts({}).filter((h) => h.kind === 'Error reporting')).toEqual([]);
|
||||
const listed = hosts({ errorReportingDsn: 'https://6f0b9c79ed3a4315b76595bc2536ac40@errors.example.org/3' })
|
||||
.filter((h) => h.kind === 'Error reporting');
|
||||
expect(listed).toEqual([{ kind: 'Error reporting', host: 'errors.example.org', origin: 'https://errors.example.org', setIn: 'ERROR_REPORTING_DSN', side: 'both' }]);
|
||||
expect(noThirdPartyCspSources(listed, false).connect).toEqual(['https://errors.example.org']);
|
||||
});
|
||||
});
|
||||
+16
-1
@@ -31,6 +31,7 @@ export const ADMIN_PAGE_IDS = [
|
||||
'marketplace',
|
||||
'version',
|
||||
'telemetry',
|
||||
'monitoring',
|
||||
'logs',
|
||||
] as const;
|
||||
|
||||
@@ -287,12 +288,26 @@ export const ADMIN_PAGES: readonly AdminPageDef[] = [
|
||||
group: 'system',
|
||||
keywords: 'telemetry heartbeat anonymous consent privacy',
|
||||
sections: [
|
||||
{ id: 'status', title: 'Status', keywords: 'enable disable consent' },
|
||||
{ id: 'status', title: 'Status', keywords: 'enable disable consent error counts' },
|
||||
{ id: 'accounts', title: 'Account activity', keywords: 'active total logins' },
|
||||
{ id: 'endpoint', title: 'Endpoint', keywords: 'collector url' },
|
||||
{ id: 'payload', title: 'Payload preview', keywords: 'send now json' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'monitoring',
|
||||
label: 'Monitoring',
|
||||
description: 'Error reports, metrics, health checks and tracing for your own tools',
|
||||
group: 'system',
|
||||
keywords: 'monitoring errors sentry bugsink glitchtip dsn prometheus metrics health probes tracing opentelemetry otel',
|
||||
sections: [
|
||||
{ id: 'errors', title: 'Error reporting', keywords: 'dsn sentry bugsink glitchtip sample rate environment' },
|
||||
{ id: 'errors-today', title: 'Errors today', keywords: 'count fingerprint top' },
|
||||
{ id: 'metrics', title: 'Metrics', keywords: 'prometheus scrape token grafana' },
|
||||
{ id: 'health', title: 'Health checks', keywords: 'liveness readiness probe kubernetes docker' },
|
||||
{ id: 'tracing', title: 'Tracing', keywords: 'opentelemetry otel otlp jaeger tempo spans traceparent' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
label: 'Audit log',
|
||||
|
||||
@@ -89,6 +89,10 @@ export async function auditLog(action: string, detail: Record<string, unknown>,
|
||||
ip,
|
||||
};
|
||||
|
||||
// Also in the server log, so LOG_FORMAT=json shippers see admin actions.
|
||||
// The audit file below stays the record the admin panel reads.
|
||||
logger.info(`audit: ${action}`, { audit: entry });
|
||||
|
||||
try {
|
||||
// One line per entry; the store rotates the local file (audit.log.3 →
|
||||
// deleted, .2 → .3, .1 → .2, audit.log → .1) once it passes 10 MB.
|
||||
|
||||
@@ -39,7 +39,7 @@ export const OUTGOING_HOSTS: readonly OutgoingHost[] = [
|
||||
host: 'telemetry.bulwarkmail.org',
|
||||
files: ['lib/telemetry/types.ts'],
|
||||
side: 'server',
|
||||
usedBy: 'Anonymous daily heartbeat, sent only after an admin turned telemetry on.',
|
||||
usedBy: 'Anonymous daily heartbeat, sent only after an admin turned telemetry on; with instant error counts on, also the current day\'s error counts as they change.',
|
||||
off: 'Off by default. BULWARK_TELEMETRY=off, or System > Usage stats (switch it off or clear the collector URL).',
|
||||
inMode: 'Never sent, whatever the telemetry setting says.',
|
||||
lite: false,
|
||||
@@ -157,9 +157,10 @@ export const OUTGOING_HOSTS: readonly OutgoingHost[] = [
|
||||
* has to be looked at again.
|
||||
*/
|
||||
export const NON_REQUEST_HOSTS: readonly { host: string; files: readonly string[]; why: string }[] = [
|
||||
{ host: 'bulwarkmail.org', why: 'docs links, comments and the contact URL in User-Agent headers', files: ['app/(main)/admin/_pages/telemetry.tsx', 'app/api/fetch-ical/route.ts', 'app/api/translate/route.ts','components/admin/org/sections/recovery-section.tsx', 'components/admin/org/sections/company-calendars-section.tsx', 'components/admin/org/sections/free-busy-section.tsx', 'components/admin/org/sections/labels-section.tsx', 'components/admin/org/sections/out-of-office-calendar-section.tsx', 'components/admin/org/sections/phishing-report-section.tsx', 'instrumentation.node.ts', 'lib/telemetry/state.ts', 'lib/telemetry/types.ts'] },
|
||||
{ host: 'bulwarkmail.org', why: 'docs links, comments and the contact URL in User-Agent headers', files: ['app/(main)/admin/_pages/telemetry.tsx', 'app/api/fetch-ical/route.ts', 'app/api/translate/route.ts', 'components/admin/org/sections/recovery-section.tsx', 'components/admin/org/sections/company-calendars-section.tsx', 'components/admin/org/sections/free-busy-section.tsx', 'components/admin/org/sections/labels-section.tsx', 'components/admin/org/sections/out-of-office-calendar-section.tsx', 'components/admin/org/sections/phishing-report-section.tsx', 'instrumentation.node.ts', 'lib/telemetry/state.ts', 'lib/telemetry/types.ts', 'app/(main)/admin/_pages/monitoring.tsx', 'lib/admin/types.ts', 'lib/error-reporting/client.ts', 'lib/error-reporting/lite.ts', 'lib/error-reporting/scrub.ts', 'instrumentation.ts', 'app/api/health/live/route.ts', 'app/api/health/ready/route.ts', 'app/api/metrics/route.ts', 'lib/metrics/index.ts', 'lib/tracing/config.ts', 'lib/tracing/scrub.ts', 'lib/tracing/start.ts'] },
|
||||
{ host: 'bulwarkmail.org', why: 'docs links of the Notices, Devices (offline mode too) and out-of-office template pages', files: ['components/admin/org/sections/announcement-section.tsx', 'components/admin/org/sections/devices-section.tsx', 'components/admin/org/sections/offline-section.tsx', 'components/admin/org/sections/retention-section.tsx', 'components/admin/org/sections/vacation-templates-section.tsx'] },
|
||||
{ host: 'bulwarkmail.com', why: 'JMAP capability URI', files: ['lib/jmap/client.ts'] },
|
||||
{ host: 'develop.sentry.dev', why: 'link to the envelope format in a comment', files: ['lib/error-reporting/envelope.ts'] },
|
||||
{ host: 'extensions.bulwarkmail.org', why: 'signed statement type URIs', files: ['lib/admin/directory-keys.ts'] },
|
||||
{ host: 'www.w3.org', why: 'XML namespaces', files: ['components/ui/flag-icons.tsx', 'lib/favicon-badge.ts'] },
|
||||
{ host: 'creativecommons.org', why: 'licence metadata inside an SVG', files: ['components/ui/flag-icons.tsx'] },
|
||||
|
||||
+31
-1
@@ -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) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { parseJmapServers } from './jmap-servers';
|
||||
import { isBuiltInHost } from '@/lib/outgoing-hosts';
|
||||
import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
|
||||
import { allFeeds } from './company-feeds';
|
||||
import { tracingEndpoint } from '@/lib/tracing/config';
|
||||
|
||||
/**
|
||||
* What stays reachable with "no third-party traffic" on: the hosts the
|
||||
@@ -105,6 +106,11 @@ export function listReachableHosts({ get, policy, env, buildRelayUrl = DEFAULT_R
|
||||
for (const app of policy.defaultSidebarApps ?? []) add('Sidebar app', app?.url, `User policy > Sidebar apps (${app?.name ?? ''})`, 'browser');
|
||||
add('Document editor (WOPI)', get('wopiClientUrl'), 'WOPI_CLIENT_URL', 'both');
|
||||
add('Translation (LibreTranslate)', env.LIBRETRANSLATE_URL, 'LIBRETRANSLATE_URL', 'server');
|
||||
// Only when an admin set it; browsers and this server send error reports there.
|
||||
add('Error reporting', get('errorReportingDsn'), 'ERROR_REPORTING_DSN', 'both');
|
||||
// OpenTelemetry's OTLP endpoint, only when set in the standard OTEL_* variables.
|
||||
const otlp = tracingEndpoint(env);
|
||||
if (otlp) add('Tracing (OTLP)', otlp, env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ? 'OTEL_EXPORTER_OTLP_TRACES_ENDPOINT' : 'OTEL_EXPORTER_OTLP_ENDPOINT', 'server');
|
||||
for (const key of BRANDING_KEYS) add('Branding image', get(key), key, 'browser');
|
||||
|
||||
// User-directed: what users type in themselves stays allowed.
|
||||
|
||||
@@ -363,6 +363,12 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
||||
settingsSyncEnabled: { envVar: 'SETTINGS_SYNC_ENABLED', type: 'boolean', defaultValue: false },
|
||||
logFormat: { envVar: 'LOG_FORMAT', type: 'enum', defaultValue: 'text', enumValues: ['text', 'json'] },
|
||||
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
|
||||
// Error reporting to a Sentry-protocol tracker (Bugsink, GlitchTip,
|
||||
// Sentry), off while empty. The DSN is public by design: browsers get it.
|
||||
// https://bulwarkmail.org/docs/deployment/monitoring
|
||||
errorReportingDsn: { envVar: 'ERROR_REPORTING_DSN', fileEnvVar: 'ERROR_REPORTING_DSN_FILE', fileKey: 'errorReportingDsnFile', type: 'string', defaultValue: '' },
|
||||
errorReportingSampleRate: { envVar: 'ERROR_REPORTING_SAMPLE_RATE', type: 'string', defaultValue: '1' },
|
||||
errorReportingEnvironment: { envVar: 'ERROR_REPORTING_ENVIRONMENT', type: 'string', defaultValue: 'production' },
|
||||
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', fileKey: "sessionSecretFile", type: 'string', defaultValue: '' },
|
||||
extensionDirectoryUrl: { envVar: 'EXTENSION_DIRECTORY_URL', type: 'url', defaultValue: 'https://extensions.bulwarkmail.org' },
|
||||
// Root keys (base64 raw Ed25519, comma-separated) a self-hosted extension
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type PublicFetchResponse,
|
||||
} from '@/lib/security/url-guard';
|
||||
import { forwardedClientHeaders } from '@/lib/auth/client-address';
|
||||
import { observeUpstream } from '@/lib/metrics/upstream';
|
||||
|
||||
const VERIFY_TIMEOUT_MS = 10000;
|
||||
const MAX_REDIRECTS = 3;
|
||||
@@ -150,7 +151,7 @@ export function assertBasicAuthMatchesUsername(authHeader: string, claimedUserna
|
||||
|
||||
export interface VerifiedJmapSession {
|
||||
apiUrl: string;
|
||||
/** Canonical login name the server reports for the credential (RFC 8620 §2). */
|
||||
/** Canonical login name the server reports for the credential (RFC 8620 §2). */
|
||||
username?: string;
|
||||
primaryAccounts?: Record<string, string>;
|
||||
accounts: Record<string, unknown>;
|
||||
@@ -361,9 +362,13 @@ async function fetchIdentityEmails(
|
||||
};
|
||||
// No redirect handling here: a redirect on the POST would downgrade it to
|
||||
// a GET and can only come from a misconfigured proxy - treat as failure.
|
||||
const response = options.trusted
|
||||
? await fetch(apiUrl, { ...requestInit, redirect: 'manual' })
|
||||
: await fetchPublicUrl(apiUrl, requestInit);
|
||||
// Measured and traced like every call to Stalwart (lib/metrics/upstream.ts).
|
||||
const response = await observeUpstream('stalwart', async (trace) => {
|
||||
const init = { ...requestInit, headers: { ...requestInit.headers, ...trace } };
|
||||
return options.trusted
|
||||
? await fetch(apiUrl, { ...init, redirect: 'manual' })
|
||||
: await fetchPublicUrl(apiUrl, init);
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new JmapAuthVerificationError(
|
||||
response.status === 401 || response.status === 403
|
||||
@@ -435,9 +440,13 @@ async function fetchVerifiedSession(
|
||||
// Untrusted (user-supplied) endpoints connect through the rebinding-safe
|
||||
// fetch so the address validated above is the one the socket uses.
|
||||
// Admin-configured servers may legitimately live on private addresses.
|
||||
response = options.trusted
|
||||
? await fetch(currentUrl, { ...requestInit, redirect: 'manual' })
|
||||
: await fetchPublicUrl(currentUrl, requestInit);
|
||||
const target = currentUrl;
|
||||
response = await observeUpstream('stalwart', async (trace) => {
|
||||
const init = { ...requestInit, headers: { ...requestInit.headers, ...trace } };
|
||||
return options.trusted
|
||||
? await fetch(target, { ...init, redirect: 'manual' })
|
||||
: await fetchPublicUrl(target, init);
|
||||
});
|
||||
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('location');
|
||||
|
||||
@@ -74,6 +74,14 @@ const configValidators = {
|
||||
noThirdPartyTraffic: isBoolean,
|
||||
// Lite only (lib/lite-config.ts); the server's response leaves it out.
|
||||
oauthRevokeOnSignOut: (value): value is boolean | undefined => value === undefined || typeof value === 'boolean',
|
||||
// Absent from Lite's config and from servers before 1.20.
|
||||
errorReporting: (value): value is ConfigData['errorReporting'] => value === undefined || (
|
||||
isRecord(value)
|
||||
&& typeof value.dsn === 'string'
|
||||
&& typeof value.sampleRate === 'number'
|
||||
&& typeof value.environment === 'string'
|
||||
&& typeof value.collect === 'boolean'
|
||||
),
|
||||
} satisfies Validators<ConfigData>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { bound, KEEP_DAYS, MAX_PER_DAY, merge, summarizeDay, type Aggregate, type AggregateEntry } from '../aggregate';
|
||||
|
||||
function entry(count: number): AggregateEntry {
|
||||
return { name: 'Error', frames: [], version: '1.20.0', runtime: 'browser', count };
|
||||
}
|
||||
|
||||
describe('error aggregate', () => {
|
||||
it('adds counts from several replicas instead of overwriting them', () => {
|
||||
const a: Aggregate = { '2026-11-01': { f1: entry(2) } };
|
||||
const b: Aggregate = { '2026-11-01': { f1: entry(3), f2: entry(1) } };
|
||||
expect(merge(a, b)['2026-11-01']).toEqual({ f1: entry(5), f2: entry(1) });
|
||||
});
|
||||
|
||||
it('keeps the most frequent fingerprints per day and only the last days', () => {
|
||||
const many: Record<string, AggregateEntry> = {};
|
||||
for (let i = 0; i < MAX_PER_DAY + 50; i++) many[`f${i}`] = entry(i + 1);
|
||||
const old = '2026-10-01';
|
||||
const bounded = bound({ '2026-11-01': many, [old]: { x: entry(1) } }, '2026-11-01');
|
||||
expect(Object.keys(bounded['2026-11-01'])).toHaveLength(MAX_PER_DAY);
|
||||
expect(bounded['2026-11-01'].f0).toBeUndefined();
|
||||
expect(bounded['2026-11-01'][`f${MAX_PER_DAY + 49}`]).toBeDefined();
|
||||
expect(bounded[old]).toBeUndefined();
|
||||
const lastKept = new Date(Date.parse('2026-11-01T00:00:00Z') - (KEEP_DAYS - 1) * 86_400_000).toISOString().slice(0, 10);
|
||||
expect(Object.keys(bound({ [lastKept]: { x: entry(1) } }, '2026-11-01'))).toEqual([lastKept]);
|
||||
});
|
||||
|
||||
it('summarizes a day, most frequent first', () => {
|
||||
const summary = summarizeDay({ '2026-11-01': { a: entry(1), b: entry(5) } }, '2026-11-01');
|
||||
expect(summary.total).toBe(6);
|
||||
expect(summary.top.map((e) => e.fingerprint)).toEqual(['b', 'a']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { captureException, configureErrorReporting, resetErrorReportingForTests } from '../client';
|
||||
|
||||
const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3';
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BULWARK_LITE', '1');
|
||||
resetErrorReportingForTests();
|
||||
sessionStorage.clear();
|
||||
fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// Bulwark Lite has no server, so it never posts to /api/errors, whatever
|
||||
// the settings say; reports go to the DSN only.
|
||||
describe('error reporting in Lite', () => {
|
||||
it('sends to the DSN and never to /api/errors', () => {
|
||||
configureErrorReporting({ dsn: DSN, sampleRate: 1, environment: 'lite', collect: true });
|
||||
const error = new TypeError('boom');
|
||||
error.stack = 'TypeError: boom\n at a (https://lite.example.org/_next/static/chunks/1.js:2:30)';
|
||||
captureException(error);
|
||||
const urls = fetchSpy.mock.calls.map(([url]) => String(url));
|
||||
expect(urls).toHaveLength(1);
|
||||
expect(urls[0]).toContain('/api/3/envelope/');
|
||||
expect(urls.some((url) => url.includes('/api/errors'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
captureException, configureErrorReporting, recordJmapCall, recordNavigation, reportFromError, reportJmapFailure,
|
||||
resetErrorReportingForTests,
|
||||
} from '../client';
|
||||
|
||||
const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3';
|
||||
const SUBJECT = 'Merger plans for Acme Q3';
|
||||
const ADDRESS = 'ceo.private@acme-corp.example';
|
||||
const FOLDER = 'Acquisition Talks';
|
||||
|
||||
function errorWithStack(message: string): Error {
|
||||
const error = new TypeError(message);
|
||||
error.stack = `TypeError: ${message}\n at Ab (https://mail.example.org/_next/static/chunks/1.js:2:30)\n at https://mail.example.org/_next/static/chunks/2.js:4:5`;
|
||||
return error;
|
||||
}
|
||||
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
function calls(): Array<{ url: string; init: RequestInit }> {
|
||||
return fetchSpy.mock.calls.map(([url, init]) => ({ url: String(url), init: init as RequestInit }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetErrorReportingForTests();
|
||||
sessionStorage.clear();
|
||||
fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('browser error reporting', () => {
|
||||
it('sends nothing while reporting is off', () => {
|
||||
configureErrorReporting(null);
|
||||
captureException(errorWithStack('boom'));
|
||||
configureErrorReporting({ dsn: '', sampleRate: 1, environment: 'production', collect: false });
|
||||
captureException(errorWithStack('boom again'));
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('holds reports until the config arrives, then sends them', () => {
|
||||
captureException(errorWithStack('early'));
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
configureErrorReporting({ dsn: DSN, sampleRate: 1, environment: 'production', collect: false });
|
||||
expect(calls().map((c) => c.url)).toEqual(['https://bugsink.example.org/api/3/envelope/?sentry_key=6f0b9c79ed3a4315b76595bc2536ac40&sentry_version=7']);
|
||||
});
|
||||
|
||||
it('sends to the DSN without cookies or referrer, and with no mail content anywhere', () => {
|
||||
configureErrorReporting({ dsn: DSN, sampleRate: 1, environment: 'staging', collect: true });
|
||||
window.history.pushState({}, '', `/en/mail/folder/${encodeURIComponent(FOLDER)}/Mabc`);
|
||||
recordNavigation(window.location.pathname);
|
||||
recordJmapCall(['Email/query', 'Email/get']);
|
||||
captureException(errorWithStack(`Failed to open "${SUBJECT}" from ${ADDRESS} at https://mail.example.org/download?name=${encodeURIComponent(SUBJECT)}`));
|
||||
|
||||
const [dsnCall, ownCall] = calls();
|
||||
expect(dsnCall.init.credentials).toBe('omit');
|
||||
expect(dsnCall.init.referrerPolicy).toBe('no-referrer');
|
||||
expect(ownCall.url).toBe('/api/errors');
|
||||
for (const { init } of [dsnCall, ownCall]) {
|
||||
const body = String(init.body);
|
||||
for (const planted of [SUBJECT, ADDRESS, FOLDER, encodeURIComponent(FOLDER), 'Merger', 'acme-corp', 'download?name']) {
|
||||
expect(body).not.toContain(planted);
|
||||
}
|
||||
}
|
||||
const event = JSON.parse(String(dsnCall.init.body).trim().split('\n')[2]);
|
||||
expect(event.exception.values[0].value).toBe('Failed to open "…" from [email] at [url]');
|
||||
expect(event.environment).toBe('staging');
|
||||
expect(event.breadcrumbs.values.map((b: { message: string }) => b.message)).toEqual(['/mail/…', 'Email/query, Email/get']);
|
||||
expect(event.tags.route).toBe('/mail/…');
|
||||
});
|
||||
|
||||
it('sends each fingerprint once per session', () => {
|
||||
configureErrorReporting({ dsn: DSN, sampleRate: 1, environment: 'production', collect: false });
|
||||
captureException(errorWithStack('same'));
|
||||
captureException(errorWithStack('same'));
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops at ten reports a minute', () => {
|
||||
configureErrorReporting({ dsn: DSN, sampleRate: 1, environment: 'production', collect: false });
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const error = new Error(`e${i}`);
|
||||
error.stack = `Error\n at https://m.example/_next/static/chunks/${i}.js:1:1`;
|
||||
captureException(error);
|
||||
}
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
|
||||
it('applies the sample rate to the DSN only', () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
configureErrorReporting({ dsn: DSN, sampleRate: 0.1, environment: 'production', collect: true });
|
||||
captureException(errorWithStack('sampled out'));
|
||||
expect(calls().map((c) => c.url)).toEqual(['/api/errors']);
|
||||
});
|
||||
|
||||
it('ignores errors that come only from extensions or other scripts', () => {
|
||||
configureErrorReporting({ dsn: DSN, sampleRate: 1, environment: 'production', collect: false });
|
||||
const error = new Error('from an extension');
|
||||
error.stack = 'Error\n at chrome-extension://abc/content.js:1:1';
|
||||
captureException(error);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a failed JMAP request by status or method and type, never the answer', () => {
|
||||
configureErrorReporting({ dsn: DSN, sampleRate: 1, environment: 'production', collect: true });
|
||||
reportJmapFailure({ status: 503 });
|
||||
reportJmapFailure({ method: 'Email/set', type: 'overQuota' });
|
||||
reportJmapFailure({ method: SUBJECT, type: ADDRESS });
|
||||
const own = calls().filter((c) => c.url === '/api/errors').map((c) => JSON.parse(String(c.init.body)));
|
||||
expect(own.map((r) => [r.name, r.code])).toEqual([
|
||||
['JmapRequestError', 'http:503'],
|
||||
['JmapMethodError', 'Email/set: overQuota'],
|
||||
['JmapMethodError', 'Unknown/method: unknown'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offline mode (#523)', () => {
|
||||
it('doesn’t report what an offline session expects', () => {
|
||||
const refused = Object.assign(errorWithStack('Not available offline'), { name: 'OfflineUnavailableError' });
|
||||
expect(reportFromError(refused, 'unhandledrejection', false)).toBeNull();
|
||||
Object.defineProperty(window.navigator, 'onLine', { configurable: true, get: () => false });
|
||||
try {
|
||||
expect(reportFromError(errorWithStack('Failed to fetch'), 'unhandledrejection', false)).toBeNull();
|
||||
} finally {
|
||||
Object.defineProperty(window.navigator, 'onLine', { configurable: true, get: () => true });
|
||||
}
|
||||
expect(reportFromError(errorWithStack('Failed to fetch'), 'unhandledrejection', false)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const consent = vi.hoisted(() => ({ value: 'off' as 'on' | 'off' | 'instant' }));
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: { ensureLoaded: async () => {}, get: (_key: string, fallback: unknown) => fallback },
|
||||
}));
|
||||
vi.mock('@/lib/telemetry/state', () => ({
|
||||
effectiveErrorsConsent: async () => ({ consent: consent.value, source: 'file' }),
|
||||
}));
|
||||
|
||||
import { shouldCollectErrors } from '../settings';
|
||||
|
||||
// Error counts in the heartbeat need errors counted on this server, so the
|
||||
// consent alone turns collection on; with nothing set it stays off.
|
||||
describe('error collection', () => {
|
||||
it('is off with no DSN, no metrics and no error-count consent', async () => {
|
||||
consent.value = 'off';
|
||||
expect(await shouldCollectErrors()).toBe(false);
|
||||
});
|
||||
|
||||
it('is on with the error-count consent alone', async () => {
|
||||
consent.value = 'on';
|
||||
expect(await shouldCollectErrors()).toBe(true);
|
||||
});
|
||||
|
||||
it('is on in instant mode too', async () => {
|
||||
consent.value = 'instant';
|
||||
expect(await shouldCollectErrors()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { buildEnvelope, buildEvent, envelopeUrl, isErrorCode, parseDsn } from '../envelope';
|
||||
import { debugIdsByPath, resetDebugIdCache } from '../debug-ids';
|
||||
import { fingerprint } from '../fingerprint';
|
||||
import { parseStack } from '../stack';
|
||||
|
||||
const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3';
|
||||
|
||||
describe('parseDsn', () => {
|
||||
it('reads key, origin and project', () => {
|
||||
expect(parseDsn(DSN)).toEqual({ origin: 'https://bugsink.example.org', pathPrefix: '', publicKey: '6f0b9c79ed3a4315b76595bc2536ac40', projectId: '3' });
|
||||
expect(parseDsn('https://6f0b9c79ed3a4315b76595bc2536ac40@host.example:8443/sentry/12')?.pathPrefix).toBe('/sentry');
|
||||
});
|
||||
|
||||
it('refuses what is not a DSN', () => {
|
||||
for (const bad of ['', 'not a url', 'https://bugsink.example.org/3', 'ftp://6f0b9c79ed3a4315b76595bc2536ac40@x.example/3',
|
||||
'https://6f0b9c79ed3a4315b76595bc2536ac40@x.example/abc', 'https://6f0b9c79ed3a4315b76595bc2536ac40:secret@x.example/3']) {
|
||||
expect(parseDsn(bad), bad).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('puts the key in the query, so a browser sends no custom header', () => {
|
||||
expect(envelopeUrl(parseDsn(DSN)!)).toBe('https://bugsink.example.org/api/3/envelope/?sentry_key=6f0b9c79ed3a4315b76595bc2536ac40&sentry_version=7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stack parsing', () => {
|
||||
it('reads V8, Firefox and Safari stacks, top frame first', () => {
|
||||
const chrome = 'TypeError: x\n at Ab (https://mail.example.org/_next/static/chunks/1.js:2:30)\n at async https://mail.example.org/_next/static/chunks/2.js:4:5';
|
||||
const firefox = 'Ab@https://mail.example.org/_next/static/chunks/1.js:2:30\n@https://mail.example.org/_next/static/chunks/2.js:4:5';
|
||||
for (const stack of [chrome, firefox]) {
|
||||
expect(parseStack(stack)).toEqual([
|
||||
{ path: '/_next/static/chunks/1.js', line: 2, column: 30, fn: 'Ab' },
|
||||
{ path: '/_next/static/chunks/2.js', line: 4, column: 5, fn: undefined },
|
||||
]);
|
||||
}
|
||||
expect(parseStack(' at Object.handler (/app/.next/server/chunks/77.js:1:900)')[0]).toEqual({ path: '/.next/server/chunks/77.js', line: 1, column: 900, fn: 'Object.handler' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('debug ids', () => {
|
||||
afterEach(() => {
|
||||
delete (globalThis as Record<string, unknown>)._debugIds;
|
||||
delete (globalThis as Record<string, unknown>)._sentryDebugIds;
|
||||
resetDebugIdCache();
|
||||
});
|
||||
|
||||
it('maps each registered chunk to its id, from Turbopack and sentry-cli alike', () => {
|
||||
(globalThis as Record<string, unknown>)._debugIds = {
|
||||
'Error\n at https://mail.example.org/_next/static/chunks/1.js:1:10': 'bc67f7db-7f3b-7be2-6f94-966cd7825cc2',
|
||||
};
|
||||
(globalThis as Record<string, unknown>)._sentryDebugIds = {
|
||||
'Error\n at https://mail.example.org/_next/static/chunks/2.js:1:10': 'c40c4654-00a8-5efe-b3a3-42818d0ccc15',
|
||||
};
|
||||
expect(Object.fromEntries(debugIdsByPath())).toEqual({
|
||||
'/_next/static/chunks/1.js': 'bc67f7db-7f3b-7be2-6f94-966cd7825cc2',
|
||||
'/_next/static/chunks/2.js': 'c40c4654-00a8-5efe-b3a3-42818d0ccc15',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEvent', () => {
|
||||
const frames = [
|
||||
{ path: '/_next/static/chunks/1.js', line: 2, column: 30, fn: 'Ab' },
|
||||
{ path: '/_next/static/chunks/2.js', line: 4, column: 5 },
|
||||
];
|
||||
const event = buildEvent(
|
||||
{ name: 'TypeError', message: 'x is undefined', frames, handled: false, mechanism: 'onerror', route: '/mail/…' },
|
||||
{
|
||||
eventId: 'a'.repeat(32), timestamp: 1, release: '1.20.0+abc1234', environment: 'production', platform: 'javascript',
|
||||
fingerprint: 'ffff0000ffff0000', debugIds: new Map([['/_next/static/chunks/1.js', 'bc67f7db-7f3b-7be2-6f94-966cd7825cc2']]),
|
||||
},
|
||||
);
|
||||
|
||||
it('names a source map image for each frame file with a debug id, under the frame path', () => {
|
||||
const exception = (event.exception as { values: Array<{ stacktrace: { frames: Array<{ abs_path: string; lineno: number }> } }> }).values[0];
|
||||
// Oldest first: the throwing frame is last.
|
||||
expect(exception.stacktrace.frames.map((f) => f.abs_path)).toEqual(['/_next/static/chunks/2.js', '/_next/static/chunks/1.js']);
|
||||
expect(event.debug_meta).toEqual({ images: [{ type: 'sourcemap', code_file: '/_next/static/chunks/1.js', debug_id: 'bc67f7db-7f3b-7be2-6f94-966cd7825cc2' }] });
|
||||
});
|
||||
|
||||
it('groups by Bulwark\'s fingerprint and carries the release', () => {
|
||||
expect(event.fingerprint).toEqual(['ffff0000ffff0000']);
|
||||
expect(event.release).toBe('1.20.0+abc1234');
|
||||
expect(event.tags).toEqual({ route: '/mail/…' });
|
||||
});
|
||||
|
||||
it('wraps it in a three-line envelope', () => {
|
||||
const lines = buildEnvelope(event).trim().split('\n');
|
||||
expect(lines).toHaveLength(3);
|
||||
expect(JSON.parse(lines[1])).toEqual({ type: 'event', content_type: 'application/json' });
|
||||
expect(JSON.parse(lines[2]).event_id).toBe('a'.repeat(32));
|
||||
});
|
||||
});
|
||||
|
||||
describe('fingerprint', () => {
|
||||
const frames = [{ path: '/_next/static/chunks/1.js', line: 2, column: 30 }];
|
||||
|
||||
it('differs by name, frame, version and code, and ignores frames outside the bundle', () => {
|
||||
const base = fingerprint('TypeError', frames, '1.20.0');
|
||||
expect(base).toMatch(/^[0-9a-f]{16}$/);
|
||||
expect(fingerprint('TypeError', frames, '1.20.0')).toBe(base);
|
||||
expect(fingerprint('RangeError', frames, '1.20.0')).not.toBe(base);
|
||||
expect(fingerprint('TypeError', [{ ...frames[0], line: 3 }], '1.20.0')).not.toBe(base);
|
||||
expect(fingerprint('TypeError', frames, '1.20.1')).not.toBe(base);
|
||||
expect(fingerprint('TypeError', frames, '1.20.0', 'serverFail')).not.toBe(base);
|
||||
expect(fingerprint('TypeError', [...frames, { path: '[other]', line: 1, column: 1 }], '1.20.0')).toBe(base);
|
||||
});
|
||||
|
||||
it('accepts only fixed error codes', () => {
|
||||
expect(isErrorCode('Email/set: overQuota')).toBe(true);
|
||||
expect(isErrorCode('serverFail')).toBe(true);
|
||||
expect(isErrorCode('http:503')).toBe(true);
|
||||
expect(isErrorCode('Merger plans for Acme')).toBe(false);
|
||||
expect(isErrorCode('Email/set: over quota now')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { liteErrorReporting } from '../lite';
|
||||
|
||||
const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@errors.example.org/3';
|
||||
|
||||
// Bulwark Lite has no server: policy.json carries the error reporting address.
|
||||
describe('Lite error reporting from policy.json', () => {
|
||||
it('is off without a valid DSN', () => {
|
||||
expect(liteErrorReporting({})).toBeNull();
|
||||
expect(liteErrorReporting(null)).toBeNull();
|
||||
expect(liteErrorReporting({ errorReporting: { dsn: 'not a dsn' } })).toBeNull();
|
||||
});
|
||||
|
||||
it('reads the DSN, sample rate and environment, and never asks for server-side counting', () => {
|
||||
expect(liteErrorReporting({ errorReporting: { dsn: DSN, sampleRate: 0.25, environment: 'lite-prod' } }))
|
||||
.toEqual({ dsn: DSN, sampleRate: 0.25, environment: 'lite-prod', collect: false });
|
||||
expect(liteErrorReporting({ errorReporting: { dsn: DSN, sampleRate: 7, environment: 'bad env!' } }))
|
||||
.toEqual({ dsn: DSN, sampleRate: 1, environment: 'production', collect: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { scrubFramePath, scrubJmapErrorType, scrubMethodName, scrubName, scrubText } from '../scrub';
|
||||
import { routeGroup, scrubRoutePath } from '@/lib/route-groups';
|
||||
|
||||
// Mail content planted where it could end up in an error: none of it may
|
||||
// survive scrubbing.
|
||||
const SUBJECT = 'Merger plans for Acme Q3';
|
||||
const ADDRESS = 'ceo.private@acme-corp.example';
|
||||
const FOLDER = 'Acquisition Talks';
|
||||
const FILE = 'term-sheet-final.pdf';
|
||||
const TOKEN = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.c2lnbmF0dXJlMTIzNDU2';
|
||||
|
||||
function expectClean(text: string) {
|
||||
for (const planted of [SUBJECT, ADDRESS, FOLDER, FILE, TOKEN, 'Merger', 'acme-corp', 'Acquisition', 'term-sheet']) {
|
||||
expect(text).not.toContain(planted);
|
||||
}
|
||||
}
|
||||
|
||||
describe('scrubText', () => {
|
||||
it('removes a quoted subject, an address and a file name from a message', () => {
|
||||
const out = scrubText(`Failed to move "${SUBJECT}" from ${ADDRESS} with attachment ${FILE}`);
|
||||
expectClean(out);
|
||||
expect(out).toBe('Failed to move "…" from [email] with attachment [file]');
|
||||
});
|
||||
|
||||
it('removes text in every kind of quote, but keeps contractions', () => {
|
||||
expect(scrubText(`can't open 'Merger' or “Merger” or ‘Merger’ or «Merger» or \`Merger\``))
|
||||
.toBe("can't open '…' or “…” or ‘…’ or «…» or `…`");
|
||||
});
|
||||
|
||||
it('removes URLs whole, with their query strings', () => {
|
||||
const out = scrubText(`fetch https://mail.acme-corp.example/jmap/download/abc/${FILE}?token=${TOKEN} failed`);
|
||||
expectClean(out);
|
||||
expect(out).toBe('fetch [url] failed');
|
||||
});
|
||||
|
||||
it('removes credentials, JWTs, key=value pairs and random-looking ids', () => {
|
||||
expect(scrubText(`Authorization: Bearer ${TOKEN}`)).toBe('Authorization: Bearer [token]');
|
||||
expect(scrubText(`token ${TOKEN} expired`)).toBe('token [token] expired');
|
||||
expect(scrubText('cookie session=abc123def; theme=dark')).toBe('cookie session=[redacted]; theme=[redacted]');
|
||||
expect(scrubText('blob Ma1b2c3d4e5f6a7b8c9d0e1f2a3b4 missing')).toBe('blob [id] missing');
|
||||
});
|
||||
|
||||
it('keeps only the first line and drops a server answer after the status', () => {
|
||||
expect(scrubText(`Request failed: 400 - {"description":"${SUBJECT}"}`)).toBe('Request failed: 400');
|
||||
expect(scrubText(`Something broke\n${SUBJECT}\n${ADDRESS}`)).toBe('Something broke');
|
||||
});
|
||||
|
||||
it('keeps engine messages readable', () => {
|
||||
expect(scrubText('Cannot read properties of undefined (reading \'id\')')).toBe('Cannot read properties of undefined (reading \'…\')');
|
||||
// Minified identifiers go too; the resolved stack names the code.
|
||||
expect(scrubText('e.map is not a function')).toBe('… is not a function');
|
||||
});
|
||||
|
||||
it('removes unquoted free text such as a folder name or a subject', () => {
|
||||
const out = scrubText('Cannot move message from ' + ADDRESS + ' to ' + FOLDER + ': ' + SUBJECT.toLowerCase());
|
||||
expectClean(out);
|
||||
expect(out).toBe('Cannot move message from [email] to …: … for …');
|
||||
});
|
||||
|
||||
it('caps the length', () => {
|
||||
expect(scrubText('x '.repeat(500)).length).toBeLessThanOrEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('route paths in breadcrumbs and reports', () => {
|
||||
it('keeps the area only, never folder names or ids', () => {
|
||||
const path = `/de/mail/folder/${encodeURIComponent(FOLDER)}/Mabc123?q=${encodeURIComponent(SUBJECT)}`;
|
||||
const out = scrubRoutePath(path);
|
||||
expectClean(decodeURIComponent(out));
|
||||
expect(out).toBe('/mail/…');
|
||||
});
|
||||
|
||||
it('removes the base path and maps unknown areas and API routes to fixed names', () => {
|
||||
expect(scrubRoutePath('/webmail/en/calendar', '/webmail')).toBe('/calendar');
|
||||
expect(scrubRoutePath(`/en/${encodeURIComponent(FOLDER)}`)).toBe('/other');
|
||||
expect(scrubRoutePath('/api/wopi/files/abc/contents')).toBe('/api/wopi/…');
|
||||
expect(routeGroup(`/api/${FOLDER}`)).toBe('api:other');
|
||||
expect(routeGroup('/_next/static/chunks/a.js')).toBe('static');
|
||||
expect(routeGroup('/')).toBe('page:home');
|
||||
});
|
||||
});
|
||||
|
||||
describe('frame paths', () => {
|
||||
it('drops the origin, base path, query and fragment', () => {
|
||||
expect(scrubFramePath('https://mail.acme-corp.example/webmail/_next/static/chunks/12-ab.js?dpl=x#y')).toBe('/_next/static/chunks/12-ab.js');
|
||||
expect(scrubFramePath('/home/alice/bulwark/.next/server/chunks/ssr/a.js')).toBe('/.next/server/chunks/ssr/a.js');
|
||||
expect(scrubFramePath('/home/alice/bulwark/node_modules/next/dist/x.js')).toBe('node_modules/next/dist/x.js');
|
||||
expect(scrubFramePath('node:internal/process/task_queues')).toBe('node:internal/process/task_queues');
|
||||
});
|
||||
|
||||
it('turns anything else into [other]', () => {
|
||||
expect(scrubFramePath('chrome-extension://abc/content.js')).toBe('[other]');
|
||||
expect(scrubFramePath(`https://acme-corp.example/${FILE}`)).toBe('[other]');
|
||||
expect(scrubFramePath('blob:https://x/1')).toBe('[other]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('names', () => {
|
||||
it('keeps identifiers only', () => {
|
||||
expect(scrubName('TypeError')).toBe('TypeError');
|
||||
expect(scrubName(SUBJECT)).toBe('Error');
|
||||
expect(scrubMethodName('Email/get')).toBe('Email/get');
|
||||
expect(scrubMethodName(SUBJECT)).toBe('Unknown/method');
|
||||
expect(scrubJmapErrorType('overQuota')).toBe('overQuota');
|
||||
expect(scrubJmapErrorType(ADDRESS)).toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const config: Record<string, unknown> = {};
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: async () => {},
|
||||
get: (key: string, fallback: unknown) => (key in config ? config[key] : fallback),
|
||||
},
|
||||
}));
|
||||
|
||||
import { captureLoggedError, captureRequestError, installServerErrorReporting } from '../server';
|
||||
import { flushErrors, readErrors, resetAggregateForTests, utcDay } from '../aggregate';
|
||||
import { logger, setErrorSink } from '@/lib/logger';
|
||||
|
||||
const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3';
|
||||
const SUBJECT = 'Merger plans for Acme Q3';
|
||||
const ADDRESS = 'ceo.private@acme-corp.example';
|
||||
|
||||
let dir: string;
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), 'bulwark-errors-'));
|
||||
process.env.TELEMETRY_DATA_DIR = dir;
|
||||
for (const key of Object.keys(config)) delete config[key];
|
||||
resetAggregateForTests();
|
||||
fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setErrorSink(null);
|
||||
resetAggregateForTests();
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.TELEMETRY_DATA_DIR;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function serverError(message: string): Error {
|
||||
const error = new Error(message);
|
||||
error.stack = `Error: ${message}\n at handler (/app/.next/server/chunks/77.js:1:900)\n at /app/node_modules/next/dist/server.js:5:6`;
|
||||
return error;
|
||||
}
|
||||
|
||||
describe('server error reporting', () => {
|
||||
it('does nothing at all without a DSN or other reason to collect', async () => {
|
||||
await captureRequestError(serverError('boom'), '/api/admin/config');
|
||||
await flushErrors();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(existsSync(path.join(dir, 'errors.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('sends a request error to the DSN with the route pattern and no mail content', async () => {
|
||||
config.errorReportingDsn = DSN;
|
||||
config.errorReportingEnvironment = 'beta';
|
||||
await captureRequestError(serverError(`Could not file "${SUBJECT}" for ${ADDRESS}`), '/api/wopi/files/[fileId]');
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe('https://bugsink.example.org/api/3/envelope/?sentry_key=6f0b9c79ed3a4315b76595bc2536ac40&sentry_version=7');
|
||||
const body = String((init as RequestInit).body);
|
||||
expect(body).not.toContain('Merger');
|
||||
expect(body).not.toContain(ADDRESS);
|
||||
const event = JSON.parse(body.trim().split('\n')[2]);
|
||||
expect(event.platform).toBe('node');
|
||||
expect(event.environment).toBe('beta');
|
||||
expect(event.tags).toEqual({ runtime: 'server', route: '/api/wopi/files/[fileId]' });
|
||||
expect(event.exception.values[0].stacktrace.frames.at(-1).abs_path).toBe('/.next/server/chunks/77.js');
|
||||
});
|
||||
|
||||
it('counts the error per day', async () => {
|
||||
config.errorReportingDsn = DSN;
|
||||
await captureRequestError(serverError('one'));
|
||||
await captureRequestError(serverError('one'));
|
||||
await flushErrors();
|
||||
const day = (await readErrors())[utcDay()];
|
||||
expect(Object.values(day).map((e) => [e.name, e.runtime, e.count])).toEqual([['Error', 'server', 2]]);
|
||||
});
|
||||
|
||||
it('reports logger.error calls with the caller\'s frames once installed', async () => {
|
||||
config.errorReportingDsn = DSN;
|
||||
await installServerErrorReporting();
|
||||
logger.error('telemetry GET error', { error: `failed for ${ADDRESS}` });
|
||||
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1));
|
||||
const event = JSON.parse(String((fetchSpy.mock.calls[0][1] as RequestInit).body).trim().split('\n')[2]);
|
||||
expect(event.exception.values[0].type).toBe('LoggedError');
|
||||
expect(event.exception.values[0].value).toBe('telemetry GET error: failed for [email]');
|
||||
});
|
||||
|
||||
it('drops the logger\'s own frames from a logged error', async () => {
|
||||
config.errorReportingDsn = DSN;
|
||||
const stack = 'Error\n at log (/app/.next/server/chunks/1.js:1:1)\n at Object.error (/app/.next/server/chunks/1.js:2:2)\n at GET (/app/.next/server/chunks/route.js:9:9)';
|
||||
await captureLoggedError('x failed', undefined, stack);
|
||||
const event = JSON.parse(String((fetchSpy.mock.calls[0][1] as RequestInit).body).trim().split('\n')[2]);
|
||||
expect(event.exception.values[0].stacktrace.frames.map((f: { function: string }) => f.function)).toEqual(['GET']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { logger } from '@/lib/logger';
|
||||
import { telemetryDocs } from '@/lib/telemetry/state';
|
||||
import { isErrorCode } from './envelope';
|
||||
import { fingerprint as fingerprintOf } from './fingerprint';
|
||||
import type { Frame } from './stack';
|
||||
import { debugIdsByPath } from './debug-ids';
|
||||
|
||||
/**
|
||||
* Errors counted per UTC day: fingerprint -> name, top frames, version and
|
||||
* how often. The admin's "errors today", the metrics endpoint and the
|
||||
* telemetry error counts read it. Only what scrubbing leaves goes in.
|
||||
*
|
||||
* Bounded: MAX_PER_DAY fingerprints per day (the rarest go first) and
|
||||
* KEEP_DAYS days. Counts gather in memory and are added to `errors.json` in
|
||||
* telemetry's document collection every FLUSH_MS, so several replicas add up
|
||||
* (an update, not an overwrite).
|
||||
*/
|
||||
|
||||
export const MAX_PER_DAY = 200;
|
||||
export const KEEP_DAYS = 8;
|
||||
export const MAX_FRAMES_KEPT = 5;
|
||||
const FLUSH_MS = 60_000;
|
||||
export const ERRORS_DOC = 'errors.json';
|
||||
|
||||
export interface AggregateEntry {
|
||||
name: string;
|
||||
frames: Frame[];
|
||||
version: string;
|
||||
/** A fixed error code when the error has one. */
|
||||
code?: string;
|
||||
runtime: 'browser' | 'server';
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** day (YYYY-MM-DD) -> fingerprint -> entry */
|
||||
export type Aggregate = Record<string, Record<string, AggregateEntry>>;
|
||||
|
||||
export interface Recordable {
|
||||
name: string;
|
||||
frames: Frame[];
|
||||
code?: string;
|
||||
}
|
||||
|
||||
interface AggregateState {
|
||||
pending: Aggregate;
|
||||
timer: ReturnType<typeof setInterval> | null;
|
||||
flushing: Promise<void> | null;
|
||||
}
|
||||
|
||||
const KEY = Symbol.for('bulwark.error-reporting.aggregate');
|
||||
function state(): AggregateState {
|
||||
const g = globalThis as typeof globalThis & { [KEY]?: AggregateState };
|
||||
return (g[KEY] ??= { pending: {}, timer: null, flushing: null });
|
||||
}
|
||||
|
||||
export function utcDay(at = Date.now()): string {
|
||||
return new Date(at).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Keep the newest KEEP_DAYS days and the MAX_PER_DAY most frequent per day. */
|
||||
export function bound(agg: Aggregate, today = utcDay()): Aggregate {
|
||||
const cutoff = utcDay(Date.parse(`${today}T00:00:00Z`) - (KEEP_DAYS - 1) * 86_400_000);
|
||||
const out: Aggregate = {};
|
||||
for (const [day, entries] of Object.entries(agg)) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || day < cutoff) continue;
|
||||
const kept = Object.entries(entries)
|
||||
.sort(([, a], [, b]) => b.count - a.count)
|
||||
.slice(0, MAX_PER_DAY);
|
||||
out[day] = Object.fromEntries(kept);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Add `delta`'s counts to `base`. */
|
||||
export function merge(base: Aggregate, delta: Aggregate): Aggregate {
|
||||
const out: Aggregate = {};
|
||||
for (const [day, entries] of Object.entries(base)) out[day] = { ...entries };
|
||||
for (const [day, entries] of Object.entries(delta)) {
|
||||
const target = (out[day] ??= {});
|
||||
for (const [print, entry] of Object.entries(entries)) {
|
||||
const existing = target[print];
|
||||
target[print] = existing ? { ...existing, count: existing.count + entry.count } : { ...entry };
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseStored(data: Buffer | null): Aggregate {
|
||||
if (!data) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(data.toString('utf8')) as unknown;
|
||||
return parsed && typeof parsed === 'object' ? (parsed as Aggregate) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Count one error. Returns its fingerprint. */
|
||||
export function recordError(report: Recordable, runtime: 'browser' | 'server', version: string, at = Date.now()): string {
|
||||
const print = fingerprintOf(report.name, report.frames, version, report.code);
|
||||
const s = state();
|
||||
const day = utcDay(at);
|
||||
const entries = (s.pending[day] ??= {});
|
||||
const existing = entries[print];
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
} else if (Object.keys(entries).length < MAX_PER_DAY) {
|
||||
entries[print] = {
|
||||
name: report.name,
|
||||
// The server's own bundle files get this process's debug IDs; browser
|
||||
// frames bring theirs along.
|
||||
frames: report.frames.slice(0, MAX_FRAMES_KEPT).map((frame) => {
|
||||
if (frame.debugId) return frame;
|
||||
const id = runtime === 'server' ? debugIdsByPath().get(frame.path) : undefined;
|
||||
return id ? { ...frame, debugId: id } : frame;
|
||||
}),
|
||||
version,
|
||||
...(isErrorCode(report.code) ? { code: report.code } : {}),
|
||||
runtime,
|
||||
count: 1,
|
||||
};
|
||||
}
|
||||
startTimer();
|
||||
return print;
|
||||
}
|
||||
|
||||
function startTimer(): void {
|
||||
const s = state();
|
||||
if (s.timer) return;
|
||||
s.timer = setInterval(() => { void flushErrors(); }, FLUSH_MS);
|
||||
s.timer.unref?.();
|
||||
}
|
||||
|
||||
/** Add what this process counted since the last flush to the stored aggregate. */
|
||||
export function flushErrors(): Promise<void> {
|
||||
const s = state();
|
||||
if (s.flushing) return s.flushing;
|
||||
const delta = s.pending;
|
||||
if (Object.keys(delta).length === 0) return Promise.resolve();
|
||||
s.pending = {};
|
||||
s.flushing = telemetryDocs()
|
||||
.update(ERRORS_DOC, (current) => JSON.stringify(bound(merge(parseStored(current), delta))))
|
||||
.then(() => undefined)
|
||||
.catch((error) => {
|
||||
// Keep the counts for the next try.
|
||||
s.pending = merge(delta, s.pending);
|
||||
logger.warn('error counts not stored', { error: error instanceof Error ? error.message : String(error) });
|
||||
})
|
||||
.finally(() => { s.flushing = null; });
|
||||
return s.flushing;
|
||||
}
|
||||
|
||||
/** The stored aggregate with this process's unflushed counts added. */
|
||||
export async function readErrors(): Promise<Aggregate> {
|
||||
let stored: Aggregate = {};
|
||||
try {
|
||||
stored = parseStored((await telemetryDocs().get(ERRORS_DOC))?.data ?? null);
|
||||
} catch (error) {
|
||||
logger.warn('error counts not readable', { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
return bound(merge(stored, state().pending));
|
||||
}
|
||||
|
||||
export interface DaySummary {
|
||||
total: number;
|
||||
top: Array<{ fingerprint: string } & AggregateEntry>;
|
||||
}
|
||||
|
||||
export function summarizeDay(agg: Aggregate, day = utcDay(), limit = 20): DaySummary {
|
||||
const entries = Object.entries(agg[day] ?? {});
|
||||
return {
|
||||
total: entries.reduce((sum, [, e]) => sum + e.count, 0),
|
||||
top: entries
|
||||
.sort(([, a], [, b]) => b.count - a.count)
|
||||
.slice(0, limit)
|
||||
.map(([fingerprint, e]) => ({ fingerprint, ...e })),
|
||||
};
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
export function resetAggregateForTests(): void {
|
||||
const s = state();
|
||||
if (s.timer) clearInterval(s.timer);
|
||||
const g = globalThis as typeof globalThis & { [KEY]?: AggregateState };
|
||||
delete g[KEY];
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { scrubRoutePath } from '@/lib/route-groups';
|
||||
import { debugIdsByPath } from './debug-ids';
|
||||
import {
|
||||
appVersion, buildEnvelope, buildEvent, envelopeUrl, isErrorCode, newEventId, parseDsn, releaseName,
|
||||
type Breadcrumb, type ErrorReport,
|
||||
} from './envelope';
|
||||
import { fingerprint } from './fingerprint';
|
||||
import { ReportLimiter } from './limiter';
|
||||
import { scrubJmapErrorType, scrubMethodName, scrubName, scrubText } from './scrub';
|
||||
import { parseStack } from './stack';
|
||||
|
||||
/**
|
||||
* Error reporting in the browser, off until the config names a DSN or asks
|
||||
* for errors to be collected (lib/error-reporting/settings.ts). Captures what
|
||||
* the error boundaries, window.onerror, unhandled rejections and failed JMAP
|
||||
* requests hand it, scrubs it (./scrub.ts) and sends it to the DSN and/or
|
||||
* this server's /api/errors. Nothing is sent before the config arrived, and
|
||||
* nothing at all when both are off.
|
||||
*
|
||||
* https://bulwarkmail.org/docs/deployment/monitoring
|
||||
*/
|
||||
|
||||
export interface ClientSettings {
|
||||
/** The error tracker's DSN; '' for none. */
|
||||
dsn: string;
|
||||
/** 0..1, applied to what goes to the DSN. */
|
||||
sampleRate: number;
|
||||
environment: string;
|
||||
/** Also post errors to this server's /api/errors (the per-day counts). */
|
||||
collect: boolean;
|
||||
}
|
||||
|
||||
const MAX_PENDING = 10;
|
||||
const MAX_BREADCRUMBS = 20;
|
||||
|
||||
interface ClientState {
|
||||
/** undefined until configured; null when reporting is off. */
|
||||
settings: ClientSettings | null | undefined;
|
||||
pending: ErrorReport[];
|
||||
breadcrumbs: Breadcrumb[];
|
||||
limiter: ReportLimiter | null;
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
// On globalThis: the error boundary, the JMAP client and the root component
|
||||
// may each end up with their own copy of this module.
|
||||
const KEY = Symbol.for('bulwark.error-reporting.client');
|
||||
function state(): ClientState {
|
||||
const g = globalThis as typeof globalThis & { [KEY]?: ClientState };
|
||||
return (g[KEY] ??= { settings: undefined, pending: [], breadcrumbs: [], limiter: null, installed: false });
|
||||
}
|
||||
|
||||
function sessionStore(): Storage | null {
|
||||
try {
|
||||
return typeof sessionStorage === 'undefined' ? null : sessionStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function limiter(): ReportLimiter {
|
||||
const s = state();
|
||||
return (s.limiter ??= new ReportLimiter({ perMinute: 10, perSession: 30, dedupWindowMs: Infinity, storage: sessionStore() }));
|
||||
}
|
||||
|
||||
function basePath(): string {
|
||||
return getPathPrefix();
|
||||
}
|
||||
|
||||
function currentRoute(): string | undefined {
|
||||
if (typeof location === 'undefined') return undefined;
|
||||
return scrubRoutePath(location.pathname, basePath());
|
||||
}
|
||||
|
||||
/** Apply the settings from /api/config (or Lite's policy.json); null switches reporting off. */
|
||||
export function configureErrorReporting(settings: ClientSettings | null): void {
|
||||
const s = state();
|
||||
const on = settings && (parseDsn(settings.dsn) || settings.collect) ? settings : null;
|
||||
s.settings = on;
|
||||
const queued = s.pending;
|
||||
s.pending = [];
|
||||
if (on) for (const report of queued) deliver(report);
|
||||
}
|
||||
|
||||
export function isErrorReportingOn(): boolean {
|
||||
return !!state().settings;
|
||||
}
|
||||
|
||||
/** A route change or a JMAP call, kept for the next report. */
|
||||
export function addBreadcrumb(category: Breadcrumb['category'], message: string): void {
|
||||
const s = state();
|
||||
if (s.settings === null) return;
|
||||
s.breadcrumbs.push({ timestamp: Date.now() / 1000, category, message });
|
||||
if (s.breadcrumbs.length > MAX_BREADCRUMBS) s.breadcrumbs.shift();
|
||||
}
|
||||
|
||||
/** A route change: only the scrubbed route is kept. */
|
||||
export function recordNavigation(pathname: string): void {
|
||||
addBreadcrumb('navigation', scrubRoutePath(pathname, basePath()));
|
||||
}
|
||||
|
||||
/** A JMAP request: only the method names. */
|
||||
export function recordJmapCall(methods: readonly string[]): void {
|
||||
addBreadcrumb('jmap', methods.slice(0, 5).map(scrubMethodName).join(', '));
|
||||
}
|
||||
|
||||
/** Turn anything thrown into a scrubbed report; null for what isn't worth one. */
|
||||
export function reportFromError(error: unknown, mechanism: string, handled: boolean): ErrorReport | null {
|
||||
let name = 'Error';
|
||||
let message = '';
|
||||
let stack: string | undefined;
|
||||
if (error instanceof Error || (error && typeof error === 'object' && 'message' in error)) {
|
||||
const e = error as { name?: unknown; message?: unknown; stack?: unknown };
|
||||
name = scrubName(e.name);
|
||||
message = scrubText(e.message);
|
||||
stack = typeof e.stack === 'string' ? e.stack : undefined;
|
||||
} else if (typeof error === 'string') {
|
||||
name = mechanism === 'unhandledrejection' ? 'UnhandledRejection' : 'Error';
|
||||
message = scrubText(error);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
// Offline mode (#523): a request the device can't answer while the
|
||||
// network is gone, and any fetch failing while the browser says it is
|
||||
// offline, are the expected state of an offline session, not bugs.
|
||||
if (name === 'OfflineUnavailableError') return null;
|
||||
if (name === 'TypeError' && typeof navigator !== 'undefined' && navigator.onLine === false) return null;
|
||||
const frames = parseStack(stack);
|
||||
// Every frame outside our bundles: a browser extension or injected script.
|
||||
if (frames.length > 0 && frames.every((frame) => frame.path === '[other]')) return null;
|
||||
return { name, message, frames, handled, mechanism, route: currentRoute() };
|
||||
}
|
||||
|
||||
/** Report an error caught by a boundary, a global handler or a caller. */
|
||||
export function captureException(error: unknown, mechanism = 'manual', handled = true): void {
|
||||
try {
|
||||
const report = reportFromError(error, mechanism, handled);
|
||||
if (report) captureReport(report);
|
||||
} catch {
|
||||
// Reporting must never throw into the code that reported.
|
||||
}
|
||||
}
|
||||
|
||||
/** Report a JMAP request that failed: the method names and the status or error type only. */
|
||||
export function reportJmapFailure(failure: { status: number } | { method: string; type: string }): void {
|
||||
try {
|
||||
const code = 'status' in failure
|
||||
? `http:${Number.isInteger(failure.status) ? failure.status : 0}`
|
||||
: `${scrubMethodName(failure.method)}: ${scrubJmapErrorType(failure.type)}`;
|
||||
if (!isErrorCode(code)) return;
|
||||
const name = 'status' in failure ? 'JmapRequestError' : 'JmapMethodError';
|
||||
const frames = parseStack(new Error().stack).slice(1);
|
||||
captureReport({
|
||||
name,
|
||||
message: code,
|
||||
code,
|
||||
frames,
|
||||
handled: true,
|
||||
mechanism: 'jmap',
|
||||
route: currentRoute(),
|
||||
});
|
||||
} catch {
|
||||
// never throws into the JMAP client
|
||||
}
|
||||
}
|
||||
|
||||
export function captureReport(report: ErrorReport): void {
|
||||
const s = state();
|
||||
if (s.settings === null) return;
|
||||
const withCrumbs = { ...report, breadcrumbs: report.breadcrumbs ?? [...s.breadcrumbs] };
|
||||
if (s.settings === undefined) {
|
||||
if (s.pending.length < MAX_PENDING) s.pending.push(withCrumbs);
|
||||
return;
|
||||
}
|
||||
deliver(withCrumbs);
|
||||
}
|
||||
|
||||
function deliver(report: ErrorReport): void {
|
||||
const s = state();
|
||||
const settings = s.settings;
|
||||
if (!settings) return;
|
||||
const version = appVersion();
|
||||
const print = fingerprint(report.name, report.frames, version, report.code);
|
||||
if (!limiter().allow(print)) return;
|
||||
|
||||
const dsn = parseDsn(settings.dsn);
|
||||
if (dsn && Math.random() < settings.sampleRate) {
|
||||
const event = buildEvent(report, {
|
||||
eventId: newEventId(),
|
||||
timestamp: Date.now() / 1000,
|
||||
release: releaseName(),
|
||||
environment: settings.environment,
|
||||
platform: 'javascript',
|
||||
fingerprint: print,
|
||||
debugIds: debugIdsByPath(),
|
||||
tags: { runtime: 'browser', ...(process.env.NEXT_PUBLIC_BULWARK_LITE === '1' ? { lite: 'yes' } : {}) },
|
||||
});
|
||||
send(envelopeUrl(dsn), buildEnvelope(event), {
|
||||
// text/plain keeps it a simple request: no preflight, no custom header.
|
||||
headers: { 'Content-Type': 'text/plain;charset=UTF-8' },
|
||||
credentials: 'omit',
|
||||
// The page URL holds folder names and message ids.
|
||||
referrerPolicy: 'no-referrer',
|
||||
mode: 'cors',
|
||||
});
|
||||
}
|
||||
// Lite has no server. Read here, not through IS_LITE: the bundler inlines
|
||||
// the variable only in this module, so the branch and the endpoint string
|
||||
// leave the Lite bundle (scripts/lite/verify.mjs).
|
||||
if (process.env.NEXT_PUBLIC_BULWARK_LITE !== '1' && settings.collect) {
|
||||
send('/api/errors', JSON.stringify({
|
||||
name: report.name,
|
||||
message: report.message,
|
||||
code: report.code,
|
||||
// With debug IDs, so the counts can point at the uploaded source maps.
|
||||
frames: withDebugIds(report.frames.slice(0, 10)),
|
||||
mechanism: report.mechanism,
|
||||
handled: report.handled,
|
||||
route: report.route,
|
||||
version,
|
||||
}), { headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin' }, true);
|
||||
}
|
||||
}
|
||||
|
||||
// A failed send is dropped quietly: it never turns into another report.
|
||||
function send(url: string, body: string, init: RequestInit, sameOrigin = false): void {
|
||||
const request = sameOrigin
|
||||
? apiFetch(url, { method: 'POST', body, keepalive: true, ...init })
|
||||
: fetch(url, { method: 'POST', body, keepalive: true, ...init });
|
||||
void Promise.resolve(request).catch(() => undefined);
|
||||
}
|
||||
|
||||
function withDebugIds(frames: ErrorReport['frames']): ErrorReport['frames'] {
|
||||
const ids = debugIdsByPath();
|
||||
return frames.map((frame) => (ids.has(frame.path) ? { ...frame, debugId: ids.get(frame.path) } : frame));
|
||||
}
|
||||
|
||||
/** window.onerror and unhandled rejections, once. */
|
||||
export function installGlobalHandlers(): void {
|
||||
const s = state();
|
||||
if (s.installed || typeof window === 'undefined') return;
|
||||
s.installed = true;
|
||||
window.addEventListener('error', (event: ErrorEvent) => {
|
||||
// "Script error." from another origin carries nothing to report.
|
||||
if (!event.error) return;
|
||||
captureException(event.error, 'onerror', false);
|
||||
});
|
||||
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
|
||||
captureException(event.reason, 'unhandledrejection', false);
|
||||
});
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
export function resetErrorReportingForTests(): void {
|
||||
const g = globalThis as typeof globalThis & { [KEY]?: ClientState };
|
||||
delete g[KEY];
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { parseStack } from './stack';
|
||||
|
||||
/**
|
||||
* The debug ID of each loaded bundle file, by scrubbed path.
|
||||
*
|
||||
* Turbopack (`turbopack.debugIds`) and `sentry-cli sourcemaps inject` (the
|
||||
* webpack build in Docker) start every chunk with a line that stores
|
||||
* `globalThis[KEY][new Error().stack] = "<debug id>"`. The first frame of that
|
||||
* stack is the chunk itself, so its path maps to the id. The error tracker
|
||||
* finds the uploaded source map by that id alone.
|
||||
*/
|
||||
|
||||
const KEYS = ['_debugIds', '_sentryDebugIds'] as const;
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
let cache: { size: number; ids: Map<string, string> } | null = null;
|
||||
|
||||
function registered(): Array<[string, string]> {
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
const out: Array<[string, string]> = [];
|
||||
for (const key of KEYS) {
|
||||
const map = g[key];
|
||||
if (!map || typeof map !== 'object') continue;
|
||||
for (const [stack, id] of Object.entries(map as Record<string, unknown>)) {
|
||||
if (typeof id === 'string' && UUID.test(id)) out.push([stack, id.toLowerCase()]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Path -> debug ID for every chunk loaded so far. Chunks load lazily, so this is rebuilt when more have registered. */
|
||||
export function debugIdsByPath(): Map<string, string> {
|
||||
const entries = registered();
|
||||
if (cache && cache.size === entries.length) return cache.ids;
|
||||
const ids = new Map<string, string>();
|
||||
for (const [stack, id] of entries) {
|
||||
const first = parseStack(stack).find((frame) => frame.path !== '[other]');
|
||||
if (first) ids.set(first.path, id);
|
||||
}
|
||||
cache = { size: entries.length, ids };
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
export function resetDebugIdCache(): void {
|
||||
cache = null;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Frame } from './stack';
|
||||
|
||||
/**
|
||||
* The Sentry envelope format, which Bugsink, GlitchTip and Sentry accept.
|
||||
* Only what Bulwark sends: one event item per envelope.
|
||||
* https://develop.sentry.dev/sdk/data-model/envelopes/
|
||||
*/
|
||||
|
||||
export interface Dsn {
|
||||
/** scheme://host[:port] */
|
||||
origin: string;
|
||||
/** Anything between the host and the project id, usually ''. */
|
||||
pathPrefix: string;
|
||||
publicKey: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/** `https://<key>@host[:port][/prefix]/<project id>`, or null. */
|
||||
export function parseDsn(raw: unknown): Dsn | null {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
|
||||
if (!url.username || url.password || url.search || url.hash) return null;
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
const projectId = parts.pop();
|
||||
if (!projectId || !/^\d+$/.test(projectId) || !/^[0-9a-f]{16,64}$/i.test(url.username)) return null;
|
||||
return {
|
||||
origin: `${url.protocol}//${url.host}`,
|
||||
pathPrefix: parts.length ? `/${parts.join('/')}` : '',
|
||||
publicKey: url.username,
|
||||
projectId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Where envelopes go. The key travels in the query, so a browser needs no custom header. */
|
||||
export function envelopeUrl(dsn: Dsn): string {
|
||||
return `${dsn.origin}${dsn.pathPrefix}/api/${dsn.projectId}/envelope/?sentry_key=${dsn.publicKey}&sentry_version=7`;
|
||||
}
|
||||
|
||||
/** A fixed error code: `Email/set: overQuota`, `serverFail`, `http:503`. */
|
||||
export const ERROR_CODE = /^(?:(?:[A-Z][A-Za-z]{0,40}\/[a-z][A-Za-z]{0,40}: )?[a-z][A-Za-z]{1,40}|http:\d{3})$/;
|
||||
|
||||
export function isErrorCode(value: unknown): value is string {
|
||||
return typeof value === 'string' && ERROR_CODE.test(value);
|
||||
}
|
||||
|
||||
export interface Breadcrumb {
|
||||
/** Seconds since the epoch. */
|
||||
timestamp: number;
|
||||
category: 'navigation' | 'jmap';
|
||||
/** A scrubbed route (`/mail/…`) or a JMAP method name. */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** An error, scrubbed, as both sides hand it to the sender. */
|
||||
export interface ErrorReport {
|
||||
name: string;
|
||||
message: string;
|
||||
frames: Frame[];
|
||||
/** One of Bulwark's fixed codes, when the error has one. */
|
||||
code?: string;
|
||||
handled: boolean;
|
||||
/** How it was caught: onerror, unhandledrejection, boundary, global-error, jmap, request, logger. */
|
||||
mechanism: string;
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
/** The scrubbed route it happened on. */
|
||||
route?: string;
|
||||
}
|
||||
|
||||
export interface EventContext {
|
||||
eventId: string;
|
||||
/** Seconds since the epoch. */
|
||||
timestamp: number;
|
||||
release: string;
|
||||
environment: string;
|
||||
platform: 'javascript' | 'node';
|
||||
fingerprint: string;
|
||||
/** Path -> debug id, for the frames' files. */
|
||||
debugIds: Map<string, string>;
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** The event payload. Frames go oldest first, as the format wants. */
|
||||
export function buildEvent(report: ErrorReport, ctx: EventContext): Record<string, unknown> {
|
||||
const frames = [...report.frames].reverse().map((frame) => ({
|
||||
abs_path: frame.path,
|
||||
filename: frame.path,
|
||||
...(frame.fn ? { function: frame.fn } : {}),
|
||||
...(frame.line !== undefined ? { lineno: frame.line } : {}),
|
||||
...(frame.column !== undefined ? { colno: frame.column } : {}),
|
||||
in_app: frame.path.startsWith('/_next/') || frame.path.startsWith('/.next/'),
|
||||
}));
|
||||
const images = [...new Set(report.frames.map((frame) => frame.path))]
|
||||
.filter((path) => ctx.debugIds.has(path))
|
||||
.map((path) => ({ type: 'sourcemap', code_file: path, debug_id: ctx.debugIds.get(path) }));
|
||||
return {
|
||||
event_id: ctx.eventId,
|
||||
timestamp: ctx.timestamp,
|
||||
platform: ctx.platform,
|
||||
level: 'error',
|
||||
release: ctx.release,
|
||||
environment: ctx.environment,
|
||||
// Bulwark's own grouping: the same key as the per-day counts.
|
||||
fingerprint: [ctx.fingerprint],
|
||||
exception: {
|
||||
values: [{
|
||||
type: report.name,
|
||||
value: report.code ?? report.message,
|
||||
mechanism: { type: report.mechanism, handled: report.handled },
|
||||
...(frames.length ? { stacktrace: { frames } } : {}),
|
||||
}],
|
||||
},
|
||||
...(report.breadcrumbs?.length
|
||||
? { breadcrumbs: { values: report.breadcrumbs.map((b) => ({ timestamp: b.timestamp, category: b.category, message: b.message })) } }
|
||||
: {}),
|
||||
...(images.length ? { debug_meta: { images } } : {}),
|
||||
tags: { ...(ctx.tags ?? {}), ...(report.route ? { route: report.route } : {}) },
|
||||
sdk: { name: 'bulwark.error-reporting', version: '1' },
|
||||
};
|
||||
}
|
||||
|
||||
/** One event in an envelope. */
|
||||
export function buildEnvelope(event: Record<string, unknown>): string {
|
||||
const header = { event_id: event.event_id, sent_at: new Date().toISOString() };
|
||||
return `${JSON.stringify(header)}\n${JSON.stringify({ type: 'event', content_type: 'application/json' })}\n${JSON.stringify(event)}\n`;
|
||||
}
|
||||
|
||||
/** 32 hex digits, as event ids are. */
|
||||
export function newEventId(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/** `<version>+<commit>`, the release the source maps were uploaded for. */
|
||||
export function releaseName(): string {
|
||||
const version = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
|
||||
const commit = process.env.NEXT_PUBLIC_GIT_COMMIT;
|
||||
return commit && commit !== 'unknown' ? `${version}+${commit}` : version;
|
||||
}
|
||||
|
||||
export function appVersion(): string {
|
||||
return process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Frame } from './stack';
|
||||
|
||||
/** How many of the top frames make up a fingerprint. */
|
||||
export const FINGERPRINT_FRAMES = 5;
|
||||
|
||||
/**
|
||||
* 64-bit FNV-1a as 16 hex digits. Synchronous and the same in the browser and
|
||||
* on the server; not for security, only for grouping.
|
||||
*/
|
||||
export function hash64(text: string): string {
|
||||
let h1 = 0x811c9dc5;
|
||||
let h2 = 0xcbf29ce4;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text.charCodeAt(i);
|
||||
h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0;
|
||||
h2 = Math.imul(h2 ^ c ^ (h1 >>> 7), 0x01000193) >>> 0;
|
||||
}
|
||||
return h1.toString(16).padStart(8, '0') + h2.toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* The fingerprint of an error: its name, the top frames (file without origin,
|
||||
* line, column) and the Bulwark version. The same error in another release is
|
||||
* another fingerprint, because its frames point into other chunks anyway.
|
||||
* `code` is one of Bulwark's fixed error codes (a JMAP error type), for
|
||||
* errors that are all raised from the same place.
|
||||
*/
|
||||
export function fingerprint(name: string, frames: readonly Frame[], version: string, code?: string): string {
|
||||
const top = frames
|
||||
.filter((frame) => frame.path !== '[other]')
|
||||
.slice(0, FINGERPRINT_FRAMES)
|
||||
.map((frame) => `${frame.path}:${frame.line ?? ''}:${frame.column ?? ''}`);
|
||||
return hash64([name, code ?? '', ...top, version].join('|'));
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Keeps error reporting from flooding anyone: each fingerprint once per
|
||||
* window, at most `perMinute` reports in any minute and `perSession` in all.
|
||||
* The browser keeps its state in sessionStorage, so a page that crashes on
|
||||
* every load doesn't start over each time.
|
||||
*/
|
||||
|
||||
export interface LimiterOptions {
|
||||
perMinute: number;
|
||||
perSession: number;
|
||||
/** How long a fingerprint stays "already sent"; Infinity for the whole session. */
|
||||
dedupWindowMs: number;
|
||||
/** Persist across page loads (sessionStorage); absent on the server. */
|
||||
storage?: Pick<Storage, 'getItem' | 'setItem'> | null;
|
||||
storageKey?: string;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
interface State {
|
||||
total: number;
|
||||
sent: Record<string, number>;
|
||||
}
|
||||
|
||||
const MAX_REMEMBERED = 500;
|
||||
|
||||
export class ReportLimiter {
|
||||
private readonly options: Required<Omit<LimiterOptions, 'storage'>> & { storage: LimiterOptions['storage'] };
|
||||
private recent: number[] = [];
|
||||
private state: State = { total: 0, sent: {} };
|
||||
|
||||
constructor(options: LimiterOptions) {
|
||||
this.options = { storageKey: 'bulwark.error-reporting', now: () => Date.now(), storage: null, ...options };
|
||||
this.load();
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
const { storage, storageKey } = this.options;
|
||||
if (!storage) return;
|
||||
try {
|
||||
const raw = storage.getItem(storageKey);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as Partial<State>;
|
||||
if (typeof parsed.total === 'number' && parsed.sent && typeof parsed.sent === 'object') {
|
||||
this.state = { total: parsed.total, sent: parsed.sent as Record<string, number> };
|
||||
}
|
||||
} catch {
|
||||
// Unreadable or blocked storage: start fresh, in memory.
|
||||
}
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
const { storage, storageKey } = this.options;
|
||||
if (!storage) return;
|
||||
try {
|
||||
storage.setItem(storageKey, JSON.stringify(this.state));
|
||||
} catch {
|
||||
// Quota or privacy mode: the in-memory state still applies.
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a report with this fingerprint may go out now; counts it when it may. */
|
||||
allow(fingerprint: string): boolean {
|
||||
const now = this.options.now();
|
||||
const last = this.state.sent[fingerprint];
|
||||
if (last !== undefined && now - last < this.options.dedupWindowMs) return false;
|
||||
if (this.state.total >= this.options.perSession) return false;
|
||||
this.recent = this.recent.filter((at) => now - at < 60_000);
|
||||
if (this.recent.length >= this.options.perMinute) return false;
|
||||
this.recent.push(now);
|
||||
this.state.total += 1;
|
||||
this.state.sent[fingerprint] = now;
|
||||
const keys = Object.keys(this.state.sent);
|
||||
if (keys.length > MAX_REMEMBERED) {
|
||||
for (const key of keys.sort((a, b) => this.state.sent[a] - this.state.sent[b]).slice(0, keys.length - MAX_REMEMBERED)) {
|
||||
delete this.state.sent[key];
|
||||
}
|
||||
}
|
||||
this.save();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ClientSettings } from './client';
|
||||
import { parseDsn } from './envelope';
|
||||
|
||||
/**
|
||||
* Bulwark Lite has no server: error reporting is set in policy.json and
|
||||
* browsers send straight to the DSN. There are no per-day counts.
|
||||
*
|
||||
* "errorReporting": { "dsn": "https://<key>@host/<project>", "sampleRate": 1, "environment": "production" }
|
||||
*
|
||||
* The DSN's host must also be in the CSP's connect-src of the Lite host.
|
||||
* https://bulwarkmail.org/docs/deployment/static#error-reporting
|
||||
*/
|
||||
export function liteErrorReporting(policy: unknown): ClientSettings | null {
|
||||
const raw = policy && typeof policy === 'object' ? (policy as Record<string, unknown>).errorReporting : undefined;
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const input = raw as Record<string, unknown>;
|
||||
const dsn = typeof input.dsn === 'string' && parseDsn(input.dsn) ? input.dsn.trim() : '';
|
||||
if (!dsn) return null;
|
||||
const rate = typeof input.sampleRate === 'number' && Number.isFinite(input.sampleRate) ? Math.min(1, Math.max(0, input.sampleRate)) : 1;
|
||||
const environment = typeof input.environment === 'string' && /^[\w.-]{1,64}$/.test(input.environment) ? input.environment : 'production';
|
||||
return { dsn, sampleRate: rate, environment, collect: false };
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* What an error report may carry, applied in the browser before sending and
|
||||
* again on the server for everything a browser posts (lib/error-reporting).
|
||||
*
|
||||
* Mail content must never leave the browser in an error, not even to the
|
||||
* admin's own error tracker: no message bodies, subjects, addresses, file
|
||||
* names, tokens, cookies or URLs. Error messages are free text, so the rules
|
||||
* remove whatever could be such content: everything after the first line,
|
||||
* quoted text, addresses, URLs, file names, credentials, long random-looking
|
||||
* strings and key=value pairs, and then every word that is not on a fixed
|
||||
* list of common words. What stays is the shape of the message
|
||||
* ("Cannot read properties of undefined (reading "…")").
|
||||
*
|
||||
* Documented at https://bulwarkmail.org/docs/deployment/monitoring#what-is-removed
|
||||
*/
|
||||
|
||||
export const MAX_MESSAGE_LENGTH = 200;
|
||||
|
||||
const REDACTED = '…';
|
||||
|
||||
// Quoted text: "…", '…', `…`, “…”, „…“, ‘…’, «…», ‹…›. A single quote only
|
||||
// counts when it starts after a space or bracket, so "can't" stays a word.
|
||||
const QUOTED: RegExp[] = [
|
||||
/"[^"\n]*"/g,
|
||||
/`[^`\n]*`/g,
|
||||
/[“„][^”“\n]*[”“]/g,
|
||||
/‘[^’\n]*’/g,
|
||||
/«[^»\n]*»/g,
|
||||
/‹[^›\n]*›/g,
|
||||
/(^|[\s([{:,=])'[^'\n]*'(?=$|[\s)\]}.,:;!?])/g,
|
||||
];
|
||||
|
||||
const EMAIL = /[^\s@<>"'`()[\]{},;:]+@[^\s@<>"'`()[\]{},;:]+\.[^\s@<>"'`()[\]{},;:]+/g;
|
||||
const URL_LIKE = /\b[a-z][a-z0-9+.-]*:\/\/[^\s"'`<>()]*/gi;
|
||||
const WWW = /\bwww\.[^\s"'`<>()]+/gi;
|
||||
const AUTH = /\b(Bearer|Basic|Token)\s+[^\s,;]+/gi;
|
||||
const JWT = /\beyJ[\w-]{5,}\.[\w-]{5,}\.[\w-]*/g;
|
||||
const KEY_VALUE = /\b([A-Za-z_][\w.-]{0,40})=([^\s&;,]+)/g;
|
||||
const FILE_NAME = /[^\s/\\"'`<>()]+\.(?:pdf|docx?|xlsx?|pptx?|odt|ods|odp|rtf|txt|csv|eml|msg|ics|vcf|zip|rar|7z|tar|gz|png|jpe?g|gif|webp|heic|svg|mp[34]|mov|wav|html?|xml|json|p7[msc]|asc|gpg|pgp|key|pem)\b/gi;
|
||||
// Runs of letters and digits that look random: ids, hashes, keys, tokens.
|
||||
const LONG_RUN = /[A-Za-z0-9+/_=-]{24,}/g;
|
||||
const IPV4 = /\b\d{1,3}(?:\.\d{1,3}){3}\b/g;
|
||||
const PHONE = /\+?\d[\d ()-]{7,}\d/g;
|
||||
|
||||
function looksRandom(run: string): boolean {
|
||||
// Long identifiers in code are words glued together: keep them when they
|
||||
// are only letters. Anything with digits or symbols mixed in goes.
|
||||
return /\d/.test(run) || /[+/=_-]/.test(run) || run.length > 40;
|
||||
}
|
||||
|
||||
/** Free text from an error, made safe to send. */
|
||||
export function scrubText(input: unknown): string {
|
||||
if (typeof input !== 'string' || input === '') return '';
|
||||
// Only the first line: server answers, stack traces and multi-line content
|
||||
// that ended up in a message all come after it.
|
||||
let text = input.split(/\r?\n/, 1)[0];
|
||||
// "Request failed: 400 - <server answer>": the answer may echo a subject.
|
||||
text = text.replace(/^(.*?\b\d{3})\s+-\s.*$/, '$1');
|
||||
text = text.replace(AUTH, '$1 [token]');
|
||||
text = text.replace(JWT, '[token]');
|
||||
text = text.replace(URL_LIKE, '[url]');
|
||||
text = text.replace(WWW, '[url]');
|
||||
text = text.replace(EMAIL, '[email]');
|
||||
for (const quoted of QUOTED) {
|
||||
text = text.replace(quoted, (match, lead?: string) => {
|
||||
const prefix = typeof lead === 'string' ? lead : '';
|
||||
const quote = match.slice(prefix.length, prefix.length + 1);
|
||||
const close = match.slice(-1);
|
||||
return `${prefix}${quote}${REDACTED}${close}`;
|
||||
});
|
||||
}
|
||||
text = text.replace(FILE_NAME, '[file]');
|
||||
text = text.replace(KEY_VALUE, '$1=[redacted]');
|
||||
text = text.replace(LONG_RUN, (run) => (looksRandom(run) ? '[id]' : run));
|
||||
text = text.replace(IPV4, '[ip]');
|
||||
text = text.replace(PHONE, '[number]');
|
||||
// Last, and the rule the others only make readable: a word stays only when
|
||||
// it is on the list of common words. Free text such as an unquoted subject
|
||||
// or folder name ("to Acquisition Talks") becomes "…".
|
||||
text = text.replace(WORD_OR_PLACEHOLDER, (token) => (
|
||||
PLACEHOLDER.test(token) || COMMON_WORDS.has(token.toLowerCase()) ? token : REDACTED
|
||||
));
|
||||
text = text.replace(/\d{5,}/g, REDACTED).replace(/…(?:[\s,;]+…)+/g, REDACTED);
|
||||
if (text.length > MAX_MESSAGE_LENGTH) text = `${text.slice(0, MAX_MESSAGE_LENGTH - 1)}…`;
|
||||
return text;
|
||||
}
|
||||
|
||||
const PLACEHOLDER = /^\[(?:email|url|token|file|id|ip|number|redacted)\]$/;
|
||||
const WORD_OR_PLACEHOLDER = /\[(?:email|url|token|file|id|ip|number|redacted)\]|[\p{L}_$][\p{L}\p{N}_$'’.-]*/gu;
|
||||
|
||||
/**
|
||||
* Words an error message may keep: English function words and the words
|
||||
* JavaScript engines, browsers, Next.js and Bulwark's own errors are made of.
|
||||
* Anything else could be content and is replaced.
|
||||
*/
|
||||
const COMMON_WORDS = new Set(`
|
||||
a about above access after again against all already an and another any are argument arguments array as at attempt
|
||||
attempted available bad be been before being body both but by call called calling can can't cannot canceled cancelled
|
||||
change changed changes child chunk class client close closed code component connect connected connection constructor
|
||||
context could create created data date decode default defined delete deleted denied did does doesn't done during each
|
||||
element empty end entry error errors event exceed exceeded exist exists expected failed fetch field file files find
|
||||
first for found from function get given got had has have header headers here hook hydration if in index initial
|
||||
instead internal invalid is isn't it item items its json key keys length limit list load loaded loading lost
|
||||
mailbox mailboxes match maximum may maybe message messages method missing more must network new no node none not
|
||||
null number object of offline on once one only open operation or out over parse parsing permission please property
|
||||
properties provided read reading ready received recursion redirect refused rejected render rendered rendering request
|
||||
requested requests required resource response result retry returned save saved script send sent server session set
|
||||
should size some stack state status still string such support supported sync syntax than that the then there these
|
||||
this thread timed timeout to token too type unable undefined unexpected unknown update updated upload uploaded use
|
||||
used user using valid value values was were when which while with within without worker write wrong
|
||||
abort aborted account accounts attachment attachments auth authentication authorization basic bearer blob broke broken check health heartbeat metrics something telemetry version blocked browser cache calendar callback contact contacts cookie
|
||||
cors credentials decrypt denied download draft email emails encrypt encrypted expired failure folder folders
|
||||
identity identities import keyword keywords login logout mail move moved oauth outbox plugin
|
||||
plugins push quota rate reply sender settings signature submission subscription template theme upload websocket
|
||||
`.split(/\s+/).filter(Boolean));
|
||||
|
||||
/** An error name: a code identifier, or "Error". */
|
||||
export function scrubName(input: unknown): string {
|
||||
return typeof input === 'string' && /^[A-Za-z_$][\w$]{0,63}$/.test(input) ? input : 'Error';
|
||||
}
|
||||
|
||||
/** A function name from a stack frame: identifiers and the usual decorations only. */
|
||||
export function scrubFunctionName(input: unknown): string | undefined {
|
||||
if (typeof input !== 'string' || input === '') return undefined;
|
||||
const trimmed = input.trim().slice(0, 120);
|
||||
return /^[\w$.<>[\] /#-]+$/.test(trimmed) && !/@/.test(trimmed) ? trimmed : '?';
|
||||
}
|
||||
|
||||
/**
|
||||
* The path of a stack frame's file, without origin, base path, query or
|
||||
* fragment: `/_next/static/chunks/x.js` for the browser bundle,
|
||||
* `/.next/server/...` for the server's, `node:...` for Node's own modules,
|
||||
* `node_modules/...` for dependencies; `[other]` for anything else
|
||||
* (extensions, plugin code, evaluated code).
|
||||
*/
|
||||
export function scrubFramePath(input: unknown): string {
|
||||
if (typeof input !== 'string' || input === '') return '[other]';
|
||||
const path = input.split(/[?#]/, 1)[0].replace(/\\/g, '/');
|
||||
for (const marker of ['/_next/static/', '/.next/server/', '/.next/static/']) {
|
||||
const at = path.indexOf(marker);
|
||||
if (at >= 0) {
|
||||
const rest = path.slice(at);
|
||||
return /^[\w./()[\]@+~-]+$/.test(rest) ? rest.replace('/.next/static/', '/_next/static/') : '[other]';
|
||||
}
|
||||
}
|
||||
if (/^node:[\w/.-]+$/.test(path)) return path;
|
||||
const modules = path.lastIndexOf('/node_modules/');
|
||||
if (modules >= 0) {
|
||||
const rest = path.slice(modules + 1);
|
||||
return /^[\w./@+~-]+$/.test(rest) ? rest : '[other]';
|
||||
}
|
||||
return '[other]';
|
||||
}
|
||||
|
||||
/** A JMAP method name ("Email/get") or a fixed placeholder. */
|
||||
export function scrubMethodName(input: unknown): string {
|
||||
return typeof input === 'string' && /^[A-Z][A-Za-z]{0,40}\/[a-z][A-Za-z]{0,40}$/.test(input) ? input : 'Unknown/method';
|
||||
}
|
||||
|
||||
/** A JMAP error type ("serverFail", "overQuota") or a fixed placeholder. */
|
||||
export function scrubJmapErrorType(input: unknown): string {
|
||||
return typeof input === 'string' && /^[a-z][A-Za-z]{1,40}$/.test(input) ? input : 'unknown';
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { recordError } from './aggregate';
|
||||
import {
|
||||
appVersion, buildEnvelope, buildEvent, envelopeUrl, newEventId, parseDsn, releaseName, type ErrorReport,
|
||||
} from './envelope';
|
||||
import { fingerprint } from './fingerprint';
|
||||
import { ReportLimiter } from './limiter';
|
||||
import { errorReportingSettings, shouldCollectErrors } from './settings';
|
||||
import { scrubName, scrubText } from './scrub';
|
||||
import { parseStack, type Frame } from './stack';
|
||||
import { debugIdsByPath } from './debug-ids';
|
||||
|
||||
/**
|
||||
* Error reporting on the server: errors Next hands to onRequestError
|
||||
* (instrumentation.ts) and errors routes catch and log with logger.error.
|
||||
* While collection is on (a DSN, the metrics endpoint or error-count
|
||||
* consent, lib/error-reporting/settings.ts) every one is counted in the
|
||||
* per-day aggregate; with a DSN it is also sent there, at most once an hour
|
||||
* per fingerprint and 60 a minute. Sending never blocks or fails the request
|
||||
* that had the error. With everything off nothing happens at all.
|
||||
*/
|
||||
|
||||
const SEND_TIMEOUT_MS = 5000;
|
||||
const ROUTE_PATTERN = /^\/[\w/[\]().@-]*$/;
|
||||
|
||||
interface ServerState {
|
||||
limiter: ReportLimiter;
|
||||
sending: number;
|
||||
}
|
||||
|
||||
const KEY = Symbol.for('bulwark.error-reporting.server');
|
||||
function state(): ServerState {
|
||||
const g = globalThis as typeof globalThis & { [KEY]?: ServerState };
|
||||
return (g[KEY] ??= {
|
||||
limiter: new ReportLimiter({ perMinute: 60, perSession: Number.POSITIVE_INFINITY, dedupWindowMs: 60 * 60_000 }),
|
||||
sending: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function deliver(report: ErrorReport): Promise<void> {
|
||||
if (!(await shouldCollectErrors())) return;
|
||||
const version = appVersion();
|
||||
recordError(report, 'server', version);
|
||||
const settings = errorReportingSettings();
|
||||
const dsn = parseDsn(settings.dsn);
|
||||
if (!dsn) return;
|
||||
const print = fingerprint(report.name, report.frames, version, report.code);
|
||||
const s = state();
|
||||
if (s.sending > 4 || !s.limiter.allow(print)) return;
|
||||
if (Math.random() >= settings.sampleRate) return;
|
||||
const event = buildEvent(report, {
|
||||
eventId: newEventId(),
|
||||
timestamp: Date.now() / 1000,
|
||||
release: releaseName(),
|
||||
environment: settings.environment,
|
||||
platform: 'node',
|
||||
fingerprint: print,
|
||||
debugIds: debugIdsByPath(),
|
||||
tags: { runtime: 'server' },
|
||||
});
|
||||
s.sending++;
|
||||
try {
|
||||
await fetch(envelopeUrl(dsn), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-sentry-envelope' },
|
||||
body: buildEnvelope(event),
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
||||
});
|
||||
} finally {
|
||||
s.sending--;
|
||||
}
|
||||
}
|
||||
|
||||
/** Count `report` and send it to the DSN when one is set. Never rejects. */
|
||||
export function handleServerReport(report: ErrorReport): Promise<void> {
|
||||
return deliver(report).catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Next's onRequestError: an error a route, page or server action threw. */
|
||||
export function captureRequestError(error: unknown, routePattern?: string): Promise<void> {
|
||||
try {
|
||||
const e = (error && typeof error === 'object' ? error : { message: String(error) }) as { name?: unknown; message?: unknown; stack?: unknown };
|
||||
return handleServerReport({
|
||||
name: scrubName(e.name),
|
||||
message: scrubText(e.message),
|
||||
frames: parseStack(e.stack),
|
||||
handled: false,
|
||||
mechanism: 'request',
|
||||
// A route pattern from the code ("/api/admin/[id]"), never the request's URL.
|
||||
...(typeof routePattern === 'string' && ROUTE_PATTERN.test(routePattern) ? { route: routePattern } : {}),
|
||||
});
|
||||
} catch {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* logger.error: a route that caught an error and logged it. The frames are
|
||||
* the logging call's, minus the logger's own `skip` frames.
|
||||
*/
|
||||
export function captureLoggedError(
|
||||
message: string,
|
||||
extra: Record<string, unknown> | undefined,
|
||||
stack: string | undefined,
|
||||
skip = 2,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const detail = typeof extra?.error === 'string' ? extra.error : extra?.error instanceof Error ? extra.error.message : '';
|
||||
const frames: Frame[] = parseStack(stack).slice(skip);
|
||||
return handleServerReport({
|
||||
name: 'LoggedError',
|
||||
message: scrubText(detail ? `${message}: ${detail}` : message),
|
||||
frames,
|
||||
handled: true,
|
||||
mechanism: 'logger',
|
||||
});
|
||||
} catch {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook the logger up (instrumentation.node.ts). The logger only knows a
|
||||
* function on globalThis, so it stays free of config code and safe for any
|
||||
* bundle that imports it.
|
||||
*/
|
||||
export async function installServerErrorReporting(): Promise<void> {
|
||||
await configManager.ensureLoaded();
|
||||
const { setErrorSink } = await import('@/lib/logger');
|
||||
setErrorSink((message, extra, stack) => { void captureLoggedError(message, extra, stack); });
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { parseDsn } from './envelope';
|
||||
import { metricsEnabled } from '@/lib/metrics';
|
||||
import { effectiveErrorsConsent } from '@/lib/telemetry/state';
|
||||
import type { ClientSettings } from './client';
|
||||
|
||||
/**
|
||||
* The error-reporting settings as the server resolves them: admin config,
|
||||
* then ERROR_REPORTING_DSN(_FILE) / ERROR_REPORTING_SAMPLE_RATE /
|
||||
* ERROR_REPORTING_ENVIRONMENT, then off. Bulwark Lite reads the same three
|
||||
* from policy.json (lib/error-reporting/lite.ts).
|
||||
*/
|
||||
|
||||
export interface ErrorReportingSettings {
|
||||
/** A valid DSN, or '' when none is set or the value doesn't parse. */
|
||||
dsn: string;
|
||||
sampleRate: number;
|
||||
environment: string;
|
||||
}
|
||||
|
||||
export function parseSampleRate(value: unknown): number {
|
||||
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN;
|
||||
if (!Number.isFinite(n)) return 1;
|
||||
return Math.min(1, Math.max(0, n));
|
||||
}
|
||||
|
||||
export function parseEnvironment(value: unknown): string {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
return /^[\w.-]{1,64}$/.test(text) ? text : 'production';
|
||||
}
|
||||
|
||||
export function errorReportingSettings(): ErrorReportingSettings {
|
||||
const raw = configManager.get<string>('errorReportingDsn', '');
|
||||
return {
|
||||
dsn: parseDsn(raw) ? raw.trim() : '',
|
||||
sampleRate: parseSampleRate(configManager.get<string>('errorReportingSampleRate', '1')),
|
||||
environment: parseEnvironment(configManager.get<string>('errorReportingEnvironment', 'production')),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether browsers should post their errors to /api/errors: when there is a
|
||||
* DSN to report to, a metrics endpoint that counts them, or consent to send
|
||||
* error counts with the telemetry heartbeat.
|
||||
*/
|
||||
export async function shouldCollectErrors(): Promise<boolean> {
|
||||
await configManager.ensureLoaded();
|
||||
if (errorReportingSettings().dsn || metricsEnabled()) return true;
|
||||
return (await effectiveErrorsConsent()).consent !== 'off';
|
||||
}
|
||||
|
||||
/** What /api/config hands the browser. */
|
||||
export async function browserErrorSettings(): Promise<ClientSettings> {
|
||||
const settings = errorReportingSettings();
|
||||
return { ...settings, collect: await shouldCollectErrors() };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { scrubFramePath, scrubFunctionName } from './scrub';
|
||||
|
||||
/** One stack frame, already scrubbed. */
|
||||
export interface Frame {
|
||||
/** The file as scrubFramePath leaves it: `/_next/static/chunks/x.js`. */
|
||||
path: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
fn?: string;
|
||||
/** The debug ID of the file, when its bundle registered one (./debug-ids.ts). */
|
||||
debugId?: string;
|
||||
}
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export const MAX_FRAMES = 30;
|
||||
|
||||
// V8 (Chrome, Edge, Node): " at fn (file:1:2)", " at file:1:2",
|
||||
// " at async fn (file:1:2)", " at new Foo (file:1:2)".
|
||||
const V8 = /^\s*at (?:(.+?) \()?(.+?):(\d+):(\d+)\)?\s*$/;
|
||||
// SpiderMonkey and JavaScriptCore (Firefox, Safari): "fn@file:1:2", "@file:1:2".
|
||||
const GECKO = /^\s*([^@]*)@(.+?):(\d+):(\d+)\s*$/;
|
||||
|
||||
function number(value: string): number | undefined {
|
||||
const n = Number(value);
|
||||
return Number.isInteger(n) && n >= 0 && n < 10_000_000 ? n : undefined;
|
||||
}
|
||||
|
||||
/** The frames of a stack, the frame that threw first, at most MAX_FRAMES. */
|
||||
export function parseStack(stack: unknown): Frame[] {
|
||||
if (typeof stack !== 'string') return [];
|
||||
const frames: Frame[] = [];
|
||||
for (const line of stack.split('\n')) {
|
||||
const match = V8.exec(line) ?? GECKO.exec(line);
|
||||
if (!match) continue;
|
||||
const [, fn, file, lineNo, column] = match;
|
||||
// "at fn (native)" and eval chains carry no file worth keeping.
|
||||
frames.push({
|
||||
path: scrubFramePath(file),
|
||||
line: number(lineNo),
|
||||
column: number(column),
|
||||
fn: scrubFunctionName(fn?.replace(/^async /, '')),
|
||||
});
|
||||
if (frames.length >= MAX_FRAMES) break;
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
/** Frames handed in from outside (a browser's report), checked and scrubbed again. */
|
||||
export function sanitizeFrames(input: unknown): Frame[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
const out: Frame[] = [];
|
||||
for (const raw of input.slice(0, MAX_FRAMES)) {
|
||||
if (!raw || typeof raw !== 'object') continue;
|
||||
const frame = raw as Record<string, unknown>;
|
||||
const line = typeof frame.line === 'number' ? number(String(frame.line)) : undefined;
|
||||
const column = typeof frame.column === 'number' ? number(String(frame.column)) : undefined;
|
||||
const debugId = typeof frame.debugId === 'string' && UUID.test(frame.debugId) ? frame.debugId.toLowerCase() : undefined;
|
||||
out.push({ path: scrubFramePath(frame.path), line, column, fn: scrubFunctionName(frame.fn), ...(debugId ? { debugId } : {}) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const config: Record<string, unknown> = {};
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: async () => {},
|
||||
get: (key: string, fallback: unknown) => (key in config ? config[key] : fallback),
|
||||
},
|
||||
}));
|
||||
|
||||
import { readiness, resetReadinessCache } from '../readiness';
|
||||
import { GET as ready } from '@/app/api/health/ready/route';
|
||||
import { GET as live } from '@/app/api/health/live/route';
|
||||
|
||||
let dir: string;
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), 'bulwark-ready-'));
|
||||
process.env.ADMIN_STATE_DIR = path.join(dir, 'state');
|
||||
process.env.TELEMETRY_DATA_DIR = path.join(dir, 'telemetry');
|
||||
for (const key of Object.keys(config)) delete config[key];
|
||||
config.jmapServerUrl = 'https://mail.example.org';
|
||||
resetReadinessCache();
|
||||
fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 401 }));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.ADMIN_STATE_DIR;
|
||||
delete process.env.TELEMETRY_DATA_DIR;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('health endpoints', () => {
|
||||
it('live answers 200 whatever Stalwart does', async () => {
|
||||
fetchSpy.mockRejectedValue(new Error('down'));
|
||||
expect((await live()).status).toBe(200);
|
||||
});
|
||||
|
||||
it('ready answers 200 when Stalwart answers, even with 401', async () => {
|
||||
const res = await ready();
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ ready: true, checks: { config: 'ok', store: 'ok', dataDirs: 'ok', stalwart: 'ok' } });
|
||||
expect(String(fetchSpy.mock.calls[0][0])).toBe('https://mail.example.org/.well-known/jmap');
|
||||
});
|
||||
|
||||
it('ready answers 503 when Stalwart is unreachable or failing, and names no host', async () => {
|
||||
fetchSpy.mockRejectedValue(new Error('ECONNREFUSED mail.example.org'));
|
||||
const res = await ready();
|
||||
expect(res.status).toBe(503);
|
||||
const text = await res.text();
|
||||
expect(JSON.parse(text).checks.stalwart).toBe('fail');
|
||||
expect(text).not.toContain('example.org');
|
||||
|
||||
resetReadinessCache();
|
||||
fetchSpy.mockResolvedValue(new Response(null, { status: 502 }));
|
||||
expect((await ready()).status).toBe(503);
|
||||
});
|
||||
|
||||
it('skips Stalwart before a server is configured', async () => {
|
||||
delete config.jmapServerUrl;
|
||||
delete process.env.JMAP_SERVER_URL;
|
||||
expect((await readiness()).checks.stalwart).toBe('skipped');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails when a data dir is not writable', async () => {
|
||||
writeFileSync(path.join(dir, 'blocked'), '');
|
||||
process.env.TELEMETRY_DATA_DIR = path.join(dir, 'blocked', 'telemetry');
|
||||
const result = await readiness();
|
||||
expect(result.checks.dataDirs).toBe('fail');
|
||||
expect(result.ready).toBe(false);
|
||||
});
|
||||
|
||||
it('reuses a result for a few seconds', async () => {
|
||||
await readiness(1000);
|
||||
await readiness(2000);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { mkdir, unlink, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getStateDir } from '@/lib/admin/paths';
|
||||
import { observeUpstream } from '@/lib/metrics/upstream';
|
||||
import { bootState, getStore, isSharedMode } from '@/lib/shared-store';
|
||||
import { getSettingsDir } from '@/lib/settings-sync';
|
||||
import { getDir as getTelemetryDir } from '@/lib/telemetry/state';
|
||||
|
||||
/**
|
||||
* Whether this replica can serve, for /api/health/ready (and, for the
|
||||
* shared store, /api/ready). Each check answers ok, fail or skipped; the
|
||||
* answer names checks, never hosts or paths, because anyone may ask.
|
||||
*/
|
||||
|
||||
export type CheckResult = 'ok' | 'fail' | 'skipped';
|
||||
|
||||
export interface Readiness {
|
||||
ready: boolean;
|
||||
checks: {
|
||||
config: CheckResult;
|
||||
store: CheckResult;
|
||||
dataDirs: CheckResult;
|
||||
stalwart: CheckResult;
|
||||
};
|
||||
}
|
||||
|
||||
const STALWART_TIMEOUT_MS = 2000;
|
||||
const STORE_TIMEOUT_MS = 1000;
|
||||
const CACHE_MS = 5000;
|
||||
|
||||
const KEY = Symbol.for('bulwark.health.readiness');
|
||||
interface CacheState { at: number; result: Readiness | null; pending: Promise<Readiness> | null }
|
||||
function cache(): CacheState {
|
||||
const g = globalThis as typeof globalThis & { [KEY]?: CacheState };
|
||||
return (g[KEY] ??= { at: 0, result: null, pending: null });
|
||||
}
|
||||
|
||||
async function checkConfig(): Promise<CheckResult> {
|
||||
try {
|
||||
await configManager.ensureLoaded();
|
||||
return 'ok';
|
||||
} catch {
|
||||
return 'fail';
|
||||
}
|
||||
}
|
||||
|
||||
/** Booted, and with a shared store, the store answered a ping. */
|
||||
export async function checkStore(): Promise<CheckResult> {
|
||||
if (bootState() !== 'ready') return 'fail';
|
||||
const store = getStore();
|
||||
if (store.mode === 'local') return 'ok';
|
||||
try {
|
||||
await store.ping(STORE_TIMEOUT_MS);
|
||||
return 'ok';
|
||||
} catch {
|
||||
return 'fail';
|
||||
}
|
||||
}
|
||||
|
||||
async function writable(dir: string): Promise<boolean> {
|
||||
const probe = path.join(dir, `.ready-${process.pid}-${randomBytes(4).toString('hex')}`);
|
||||
try {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(probe, '');
|
||||
await unlink(probe);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The dirs this process writes to in local mode; a shared store holds that data instead. */
|
||||
async function checkDataDirs(): Promise<CheckResult> {
|
||||
if (isSharedMode()) return 'skipped';
|
||||
const dirs = [getStateDir(), getTelemetryDir()];
|
||||
if (configManager.get<boolean>('settingsSyncEnabled', false)) dirs.push(getSettingsDir());
|
||||
const results = await Promise.all(dirs.map(writable));
|
||||
return results.every(Boolean) ? 'ok' : 'fail';
|
||||
}
|
||||
|
||||
/** Stalwart's session resource answers, whatever the status below 500 (401 is expected without credentials). */
|
||||
async function checkStalwart(): Promise<CheckResult> {
|
||||
const base = configManager.get<string>('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || '';
|
||||
if (!base.trim()) return 'skipped';
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL('/.well-known/jmap', base.trim());
|
||||
} catch {
|
||||
return 'fail';
|
||||
}
|
||||
try {
|
||||
const response = await observeUpstream('stalwart', () => fetch(url, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(STALWART_TIMEOUT_MS),
|
||||
}));
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
return response.status < 500 ? 'ok' : 'fail';
|
||||
} catch {
|
||||
return 'fail';
|
||||
}
|
||||
}
|
||||
|
||||
async function evaluate(): Promise<Readiness> {
|
||||
const config = await checkConfig();
|
||||
const [store, dataDirs, stalwart] = await Promise.all([checkStore(), checkDataDirs(), checkStalwart()]);
|
||||
const checks = { config, store, dataDirs, stalwart };
|
||||
return { ready: Object.values(checks).every((c) => c !== 'fail'), checks };
|
||||
}
|
||||
|
||||
/** The readiness of this replica, reused for CACHE_MS so probes can't flood Stalwart or the store. */
|
||||
export async function readiness(now = Date.now()): Promise<Readiness> {
|
||||
const c = cache();
|
||||
if (c.result && now - c.at < CACHE_MS) return c.result;
|
||||
c.pending ??= evaluate()
|
||||
.then((result) => {
|
||||
c.result = result;
|
||||
c.at = Date.now();
|
||||
return result;
|
||||
})
|
||||
.finally(() => { c.pending = null; });
|
||||
return c.pending;
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
export function resetReadinessCache(): void {
|
||||
const g = globalThis as typeof globalThis & { [KEY]?: CacheState };
|
||||
delete g[KEY];
|
||||
}
|
||||
+32
-1
@@ -1,3 +1,4 @@
|
||||
import { recordJmapCall, reportJmapFailure } from "@/lib/error-reporting/client";
|
||||
import { advertisesMailboxMetadata, bulwarkEntries, escapeMetadataPathSegment, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataPatchError, MetadataRefusedError, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata";
|
||||
import { IncompleteDeleteError } from "./delete-error";
|
||||
import { downloadAllowed, saveBlobAs, type DownloadKind } from "@/lib/download-guard";
|
||||
@@ -192,6 +193,29 @@ function mailboxIdsReplacement(
|
||||
return { mailboxIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Statuses that are part of normal operation: an expired token (refreshed
|
||||
* by the caller) and Stalwart's concurrency limit (retried above).
|
||||
*/
|
||||
const UNREPORTED_STATUSES = new Set([401, 429]);
|
||||
|
||||
/**
|
||||
* Method errors that are answers, not failures: the client resyncs, pages
|
||||
* again or asks again with the new state.
|
||||
*/
|
||||
const UNREPORTED_METHOD_ERRORS = new Set(['cannotCalculateChanges', 'anchorNotFound', 'stateMismatch', 'tooManyChanges']);
|
||||
|
||||
/** Report each `["error", {type}]` answer by method name and type (lib/error-reporting/client.ts). */
|
||||
function reportMethodErrors(methodCalls: JMAPMethodCall[], response: JMAPResponse): void {
|
||||
for (const [name, result, callId] of response?.methodResponses ?? []) {
|
||||
if (name !== 'error') continue;
|
||||
const type = (result as { type?: unknown } | undefined)?.type;
|
||||
if (typeof type !== 'string' || UNREPORTED_METHOD_ERRORS.has(type)) continue;
|
||||
const method = methodCalls.find((call) => call[2] === callId)?.[0] ?? 'Unknown/method';
|
||||
reportJmapFailure({ method, type });
|
||||
}
|
||||
}
|
||||
|
||||
export class RateLimitError extends Error {
|
||||
retryAfterMs: number;
|
||||
constructor(retryAfterMs: number) {
|
||||
@@ -2141,6 +2165,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (!this.apiUrl) {
|
||||
throw new Error('Not connected. Call connect() first.');
|
||||
}
|
||||
// Error reports keep the method names of the last calls, nothing else.
|
||||
recordJmapCall(methodCalls.map(([name]) => name));
|
||||
// Offline mode (#523, lib/offline/): answered from this device when the
|
||||
// browser is offline or the network fails, and every answer from the
|
||||
// server goes to the device's cache. Without offline mode no hooks are
|
||||
@@ -2205,15 +2231,20 @@ export class JMAPClient implements IJMAPClient {
|
||||
continue;
|
||||
}
|
||||
console.error('Request failed:', response.status, responseText);
|
||||
// The status only: the answer may quote what was sent.
|
||||
if (!UNREPORTED_STATUSES.has(response.status)) reportJmapFailure({ status: response.status });
|
||||
throw new Error(`Request failed: ${response.status} - ${responseText.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
let parsed: JMAPResponse;
|
||||
try {
|
||||
return JSON.parse(responseText);
|
||||
parsed = JSON.parse(responseText);
|
||||
} catch {
|
||||
console.error('Failed to parse response:', responseText);
|
||||
throw new Error('Invalid JSON response from server');
|
||||
}
|
||||
reportMethodErrors(methodCalls, parsed);
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,38 @@ function isJson(): boolean {
|
||||
return configured === 'json';
|
||||
}
|
||||
|
||||
/** Whether log lines go out as JSON (LOG_FORMAT=json or the admin setting). */
|
||||
export function isJsonLogFormat(): boolean {
|
||||
return isJson();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets every logger.error call, with the stack of the call. The server's
|
||||
* error reporting sets it (lib/error-reporting/server.ts); without it errors
|
||||
* are only logged.
|
||||
*/
|
||||
export type ErrorSink = (message: string, extra: Record<string, unknown> | undefined, stack: string | undefined) => void;
|
||||
const SINK_KEY = Symbol.for('bulwark.logger.errorSink');
|
||||
type GlobalWithSink = typeof globalThis & { [SINK_KEY]?: ErrorSink };
|
||||
|
||||
export function setErrorSink(sink: ErrorSink | null): void {
|
||||
const target = globalThis as GlobalWithSink;
|
||||
if (sink) target[SINK_KEY] = sink;
|
||||
else delete target[SINK_KEY];
|
||||
}
|
||||
|
||||
function log(level: LogLevel, message: string, extra?: Record<string, unknown>): void {
|
||||
if (level === 'error') {
|
||||
const sink = (globalThis as GlobalWithSink)[SINK_KEY];
|
||||
if (sink) {
|
||||
try {
|
||||
// Frames: log, logger.error, then the caller.
|
||||
sink(message, extra, new Error().stack);
|
||||
} catch {
|
||||
// reporting must not break logging
|
||||
}
|
||||
}
|
||||
}
|
||||
if (LEVELS[level] > getLevel()) return;
|
||||
|
||||
if (isJson()) {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment node
|
||||
import http from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { metrics, observeRequest, resetMetricsForTests, signinOutcome } from '../index';
|
||||
import { observeUpstream } from '../upstream';
|
||||
import { installHttpMetrics } from '../http';
|
||||
|
||||
async function value(name: string, labels: Record<string, string> = {}): Promise<number | undefined> {
|
||||
const metric = (await metrics().registry.getMetricsAsJSON()).find((m) => m.name === name);
|
||||
const found = (metric?.values as Array<{ labels: Record<string, string | number>; value: number }> | undefined)
|
||||
?.find((v) => Object.entries(labels).every(([k, want]) => v.labels[k] === want));
|
||||
return found?.value;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.METRICS_TOKEN = 'test-token-1234567890';
|
||||
resetMetricsForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.METRICS_TOKEN;
|
||||
resetMetricsForTests();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('request metrics', () => {
|
||||
it('label requests by route group and status class, never by the raw path', async () => {
|
||||
observeRequest('/de/mail/folder/Acquisition%20Talks/Mabc?q=secret', 'GET', 200, 0.05);
|
||||
observeRequest('/webmail/api/wopi/files/abc/contents', 'POST', 503, 1.2, '/webmail');
|
||||
observeRequest('/api/whatever-a-user-typed', 'BREW', 404, 0.01);
|
||||
expect(await value('bulwark_http_requests_total', { group: 'page:mail', method: 'GET', status: '2xx' })).toBe(1);
|
||||
expect(await value('bulwark_http_requests_total', { group: 'api:wopi', method: 'POST', status: '5xx' })).toBe(1);
|
||||
expect(await value('bulwark_http_requests_total', { group: 'api:other', method: 'other', status: '4xx' })).toBe(1);
|
||||
const text = await metrics().registry.metrics();
|
||||
expect(text).not.toContain('Acquisition');
|
||||
expect(text).not.toContain('whatever-a-user-typed');
|
||||
expect(text).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('count sign-ins by outcome from the auth routes', async () => {
|
||||
observeRequest('/api/auth/session', 'POST', 200, 0.1);
|
||||
observeRequest('/api/auth/session', 'POST', 401, 0.1);
|
||||
observeRequest('/api/auth/session', 'POST', 429, 0.1);
|
||||
observeRequest('/api/auth/token', 'PUT', 401, 0.1);
|
||||
observeRequest('/api/auth/token', 'PUT', 200, 0.1);
|
||||
observeRequest('/api/auth/session', 'GET', 200, 0.1);
|
||||
for (const [outcome, n] of [['success', 1], ['failure', 1], ['rate_limited', 1], ['refresh_failure', 1]] as const) {
|
||||
expect(await value('bulwark_signins_total', { outcome }), outcome).toBe(n);
|
||||
}
|
||||
});
|
||||
|
||||
it('map outcomes only for sign-in and refresh calls', () => {
|
||||
expect(signinOutcome('api:auth', '/api/auth/totp-token-exchange', 'POST', 200)).toBe('success');
|
||||
expect(signinOutcome('api:auth', '/api/auth/session', 'DELETE', 200)).toBeNull();
|
||||
expect(signinOutcome('api:admin', '/api/admin/auth', 'POST', 401)).toBeNull();
|
||||
});
|
||||
|
||||
it('do nothing while the endpoint is off', async () => {
|
||||
delete process.env.METRICS_TOKEN;
|
||||
observeRequest('/api/config', 'GET', 200, 0.01);
|
||||
process.env.METRICS_TOKEN = 'test-token-1234567890';
|
||||
expect(await value('bulwark_http_requests_total', { group: 'api:config' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('come from Node\'s HTTP server itself once installed', async () => {
|
||||
expect(installHttpMetrics('')).toBe(true);
|
||||
const server = http.createServer((_req, res) => { res.statusCode = 204; res.end(); });
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
await fetch(`http://127.0.0.1:${port}/api/health/live`);
|
||||
await vi.waitFor(async () => expect(await value('bulwark_http_requests_total', { group: 'api:health', status: '2xx' })).toBe(1));
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('upstream metrics', () => {
|
||||
it('count answers below 500 as ok and 5xx or errors as error', async () => {
|
||||
await observeUpstream('stalwart', async () => new Response(null, { status: 401 }));
|
||||
await observeUpstream('stalwart', async () => new Response(null, { status: 502 }));
|
||||
await expect(observeUpstream('wopi', async () => { throw new Error('down'); })).rejects.toThrow('down');
|
||||
expect(await value('bulwark_upstream_requests_total', { target: 'stalwart', outcome: 'ok' })).toBe(1);
|
||||
expect(await value('bulwark_upstream_requests_total', { target: 'stalwart', outcome: 'error' })).toBe(1);
|
||||
expect(await value('bulwark_upstream_requests_total', { target: 'wopi', outcome: 'error' })).toBe(1);
|
||||
});
|
||||
|
||||
it('report build info', async () => {
|
||||
expect(await metrics().registry.metrics()).toMatch(/bulwark_build_info\{version="[^"]+",build="[^"]+"\} 1/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: { ensureLoaded: async () => {}, get: (_key: string, fallback: unknown) => fallback },
|
||||
}));
|
||||
|
||||
import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
|
||||
import { metrics, resetMetricsForTests } from '@/lib/metrics';
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.METRICS_TOKEN = 'scrape-secret-0123456789';
|
||||
resetMetricsForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.METRICS_TOKEN;
|
||||
resetMetricsForTests();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// The server's calls to Stalwart all go through fetchJmapServer.
|
||||
describe('Stalwart calls', () => {
|
||||
it('are counted under the stalwart target', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 200 })));
|
||||
await fetchJmapServer('https://mail.example.org/jmap/session', {}, true);
|
||||
expect(await metrics().registry.metrics()).toContain('bulwark_upstream_requests_total{target="stalwart",outcome="ok"} 1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import diagnostics from 'node:diagnostics_channel';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { metricsEnabled, observeRequest } from './index';
|
||||
|
||||
/**
|
||||
* Counts every request this process serves, through Node's own HTTP
|
||||
* diagnostics channels: nothing is wrapped or patched, and the proxy's
|
||||
* matcher doesn't matter. Installed once at startup when the metrics
|
||||
* endpoint is on (instrumentation.node.ts).
|
||||
*/
|
||||
|
||||
interface HttpState {
|
||||
installed: boolean;
|
||||
started: WeakMap<IncomingMessage, number>;
|
||||
}
|
||||
|
||||
const KEY = Symbol.for('bulwark.metrics.http');
|
||||
function state(): HttpState {
|
||||
const g = globalThis as typeof globalThis & { [KEY]?: HttpState };
|
||||
return (g[KEY] ??= { installed: false, started: new WeakMap() });
|
||||
}
|
||||
|
||||
export function installHttpMetrics(basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''): boolean {
|
||||
const s = state();
|
||||
if (s.installed || !metricsEnabled()) return false;
|
||||
s.installed = true;
|
||||
diagnostics.subscribe('http.server.request.start', (message) => {
|
||||
const { request } = message as { request: IncomingMessage };
|
||||
s.started.set(request, performance.now());
|
||||
});
|
||||
diagnostics.subscribe('http.server.response.finish', (message) => {
|
||||
const { request, response } = message as { request: IncomingMessage; response: ServerResponse };
|
||||
const start = s.started.get(request);
|
||||
if (start === undefined) return;
|
||||
s.started.delete(request);
|
||||
observeRequest(request.url ?? '/', request.method, response.statusCode, (performance.now() - start) / 1000, basePath);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import type { Counter, Gauge, Histogram, Registry } from '@prometheus-io/client';
|
||||
import client from '@prometheus-io/client';
|
||||
import { routeGroup } from '@/lib/route-groups';
|
||||
|
||||
/**
|
||||
* Prometheus metrics for /api/metrics, off unless METRICS_TOKEN (or
|
||||
* METRICS_TOKEN_FILE) is set. Every replica counts for itself; Prometheus
|
||||
* scrapes each one. Labels come from fixed lists only (route groups, sign-in
|
||||
* outcomes, upstream targets), never from user names or hosts users type.
|
||||
*
|
||||
* https://bulwarkmail.org/docs/deployment/monitoring#metrics
|
||||
*/
|
||||
|
||||
export const SIGNIN_OUTCOMES = ['success', 'failure', 'refresh_failure', 'rate_limited'] as const;
|
||||
export type SigninOutcome = typeof SIGNIN_OUTCOMES[number];
|
||||
|
||||
export const UPSTREAM_TARGETS = ['stalwart', 'wopi', 'translate', 'plugin_proxy', 'oauth'] as const;
|
||||
export type UpstreamTarget = typeof UPSTREAM_TARGETS[number];
|
||||
|
||||
const METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']);
|
||||
const DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30];
|
||||
|
||||
/** The bearer token /api/metrics wants, or null when the endpoint is off. */
|
||||
export function metricsToken(): string | null {
|
||||
const direct = process.env.METRICS_TOKEN?.trim();
|
||||
if (direct) return direct;
|
||||
const file = process.env.METRICS_TOKEN_FILE?.trim();
|
||||
if (!file) return null;
|
||||
try {
|
||||
return readFileSync(file, 'utf8').trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function metricsEnabled(): boolean {
|
||||
return metricsToken() !== null;
|
||||
}
|
||||
|
||||
interface Metrics {
|
||||
registry: Registry;
|
||||
httpRequests: Counter<'group' | 'method' | 'status'>;
|
||||
httpDuration: Histogram<'group' | 'method'>;
|
||||
signins: Counter<'outcome'>;
|
||||
upstreamRequests: Counter<'target' | 'outcome'>;
|
||||
upstreamDuration: Histogram<'target'>;
|
||||
browserErrors: Counter;
|
||||
errorsToday: Gauge;
|
||||
}
|
||||
|
||||
const KEY = Symbol.for('bulwark.metrics');
|
||||
|
||||
/** The registry and metrics, made once per process whatever bundle asks. */
|
||||
export function metrics(): Metrics {
|
||||
const g = globalThis as typeof globalThis & { [KEY]?: Metrics };
|
||||
if (g[KEY]) return g[KEY];
|
||||
const registry = new client.Registry();
|
||||
client.collectDefaultMetrics({ register: registry, prefix: 'bulwark_' });
|
||||
const version = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0';
|
||||
const build = process.env.NEXT_PUBLIC_GIT_COMMIT || process.env.BULWARK_BUILD || 'unknown';
|
||||
new client.Gauge({ name: 'bulwark_build_info', help: 'Bulwark version and build; always 1', labelNames: ['version', 'build'], registers: [registry] })
|
||||
.set({ version, build }, 1);
|
||||
new client.Gauge({
|
||||
name: 'bulwark_uptime_seconds',
|
||||
help: 'Seconds since this process started',
|
||||
registers: [registry],
|
||||
collect() { this.set(process.uptime()); },
|
||||
});
|
||||
const created: Metrics = {
|
||||
registry,
|
||||
httpRequests: new client.Counter({ name: 'bulwark_http_requests_total', help: 'HTTP requests by route group, method and status class', labelNames: ['group', 'method', 'status'], registers: [registry] }),
|
||||
httpDuration: new client.Histogram({ name: 'bulwark_http_request_duration_seconds', help: 'HTTP request duration by route group and method', labelNames: ['group', 'method'], buckets: DURATION_BUCKETS, registers: [registry] }),
|
||||
signins: new client.Counter({ name: 'bulwark_signins_total', help: 'Sign-ins handled by this server, by outcome', labelNames: ['outcome'], registers: [registry] }),
|
||||
upstreamRequests: new client.Counter({ name: 'bulwark_upstream_requests_total', help: 'Requests this server made to other services, by target and outcome', labelNames: ['target', 'outcome'], registers: [registry] }),
|
||||
upstreamDuration: new client.Histogram({ name: 'bulwark_upstream_request_duration_seconds', help: 'Duration of requests to other services, by target', labelNames: ['target'], buckets: DURATION_BUCKETS, registers: [registry] }),
|
||||
browserErrors: new client.Counter({ name: 'bulwark_browser_errors_total', help: 'Errors browsers reported to this server', registers: [registry] }),
|
||||
errorsToday: new client.Gauge({
|
||||
name: 'bulwark_errors_today',
|
||||
help: 'Errors counted today (UTC) by the whole deployment, browsers and servers',
|
||||
registers: [registry],
|
||||
async collect() {
|
||||
const { readErrors, summarizeDay } = await import('@/lib/error-reporting/aggregate');
|
||||
this.set(summarizeDay(await readErrors()).total);
|
||||
},
|
||||
}),
|
||||
};
|
||||
// Pre-create the label sets, so a series exists at 0 before its first event.
|
||||
for (const outcome of SIGNIN_OUTCOMES) created.signins.inc({ outcome }, 0);
|
||||
for (const target of UPSTREAM_TARGETS) {
|
||||
for (const outcome of ['ok', 'error']) created.upstreamRequests.inc({ target, outcome }, 0);
|
||||
}
|
||||
g[KEY] = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
export function methodLabel(method: string | undefined): string {
|
||||
const upper = (method ?? '').toUpperCase();
|
||||
return METHODS.has(upper) ? upper : 'other';
|
||||
}
|
||||
|
||||
export function statusClass(status: number): string {
|
||||
return status >= 100 && status < 600 ? `${Math.floor(status / 100)}xx` : 'other';
|
||||
}
|
||||
|
||||
/** What a finished request means for sign-ins, from its route, method and status. */
|
||||
export function signinOutcome(group: string, path: string, method: string, status: number): SigninOutcome | null {
|
||||
if (group !== 'api:auth') return null;
|
||||
const route = path.split(/[?#]/, 1)[0].replace(/\/+$/, '');
|
||||
const signIn = method === 'POST' && /\/api\/auth\/(?:session|stalwart-context|totp-token-exchange|token)$/.test(route);
|
||||
const refresh = method === 'PUT' && /\/api\/auth\/token$/.test(route);
|
||||
if (!signIn && !refresh) return null;
|
||||
if (status === 429) return 'rate_limited';
|
||||
if (status >= 200 && status < 300) return refresh ? null : 'success';
|
||||
return refresh ? 'refresh_failure' : 'failure';
|
||||
}
|
||||
|
||||
/** Count a finished HTTP request (lib/metrics/http.ts). */
|
||||
export function observeRequest(path: string, method: string | undefined, status: number, seconds: number, basePath = ''): void {
|
||||
if (!metricsEnabled()) return;
|
||||
const m = metrics();
|
||||
const group = routeGroup(path, basePath);
|
||||
const verb = methodLabel(method);
|
||||
m.httpRequests.inc({ group, method: verb, status: statusClass(status) });
|
||||
m.httpDuration.observe({ group, method: verb }, seconds);
|
||||
const outcome = signinOutcome(group, basePath && path.startsWith(basePath) ? path.slice(basePath.length) : path, verb, status);
|
||||
if (outcome) m.signins.inc({ outcome });
|
||||
}
|
||||
|
||||
export function observeUpstreamResult(target: UpstreamTarget, ok: boolean, seconds: number): void {
|
||||
if (!metricsEnabled()) return;
|
||||
const m = metrics();
|
||||
m.upstreamRequests.inc({ target, outcome: ok ? 'ok' : 'error' });
|
||||
m.upstreamDuration.observe({ target }, seconds);
|
||||
}
|
||||
|
||||
export function countBrowserError(): void {
|
||||
if (!metricsEnabled()) return;
|
||||
metrics().browserErrors.inc();
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
export function resetMetricsForTests(): void {
|
||||
const g = globalThis as typeof globalThis & { [KEY]?: Metrics };
|
||||
g[KEY]?.registry.clear();
|
||||
delete g[KEY];
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { observeUpstreamResult, type UpstreamTarget } from './index';
|
||||
import { isTracingActive } from '@/lib/tracing/config';
|
||||
|
||||
/** Headers that carry the trace context to the service called (`traceparent`). */
|
||||
export type TraceHeaders = Record<string, string>;
|
||||
|
||||
/**
|
||||
* Run one request this server makes to another service, under a fixed
|
||||
* target name:
|
||||
*
|
||||
* - metrics (when /api/metrics is on): duration and outcome. An answer
|
||||
* below 500 counts as ok (a 401 from Stalwart is an answer); a 5xx or a
|
||||
* thrown error as error.
|
||||
* - tracing (when OTEL_* is set): a client span named after the target,
|
||||
* with no URL, host or user in it, and the W3C trace context handed to
|
||||
* `run` as headers, so the callee's spans join the trace.
|
||||
*
|
||||
* Costs nothing while both are off.
|
||||
*/
|
||||
export async function observeUpstream<T extends { status: number }>(
|
||||
target: UpstreamTarget,
|
||||
run: (traceHeaders: TraceHeaders) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (isTracingActive()) return traced(target, run);
|
||||
return measured(target, () => run({}));
|
||||
}
|
||||
|
||||
async function measured<T extends { status: number }>(target: UpstreamTarget, run: () => Promise<T>): Promise<T> {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const response = await run();
|
||||
observeUpstreamResult(target, response.status < 500, (performance.now() - start) / 1000);
|
||||
return response;
|
||||
} catch (error) {
|
||||
observeUpstreamResult(target, false, (performance.now() - start) / 1000);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function traced<T extends { status: number }>(target: UpstreamTarget, run: (traceHeaders: TraceHeaders) => Promise<T>): Promise<T> {
|
||||
const api = await import('@opentelemetry/api');
|
||||
const tracer = api.trace.getTracer('bulwark-webmail');
|
||||
return tracer.startActiveSpan(`upstream ${target}`, {
|
||||
kind: api.SpanKind.CLIENT,
|
||||
attributes: { 'bulwark.upstream.target': target },
|
||||
}, async (span) => {
|
||||
const headers: TraceHeaders = {};
|
||||
api.propagation.inject(api.context.active(), headers);
|
||||
try {
|
||||
const response = await measured(target, () => run(headers));
|
||||
span.setAttribute('http.response.status_code', response.status);
|
||||
if (response.status >= 500) span.setStatus({ code: api.SpanStatusCode.ERROR });
|
||||
return response;
|
||||
} catch (error) {
|
||||
// The error's name only: messages can name hosts or paths.
|
||||
span.setStatus({ code: api.SpanStatusCode.ERROR, message: error instanceof Error ? error.name : 'Error' });
|
||||
throw error;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { logger } from '@/lib/logger';
|
||||
import { observeUpstream } from '@/lib/metrics/upstream';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import type { EndpointValidator, OAuthMetadata } from '@/lib/oauth/discovery';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
@@ -209,12 +210,12 @@ export async function exchangeCodeForTokens(
|
||||
// The body carries the code, the PKCE verifier and the client secret. The
|
||||
// endpoint was validated; a redirect would re-send all of it to a host
|
||||
// that was not, so none is followed.
|
||||
const tokenResponse = await fetch(tokenEndpoint, {
|
||||
const tokenResponse = await observeUpstream('oauth', () => fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
redirect: 'error',
|
||||
});
|
||||
}));
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getPlugin } from '@/lib/admin/plugin-registry';
|
||||
import { getPluginConfig } from '@/lib/admin/plugin-config';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { DisallowedUrlError, fetchPublicUrl } from '@/lib/security/url-guard';
|
||||
import { observeUpstream } from '@/lib/metrics/upstream';
|
||||
import { authHeaderFor, credentialOrigin, privateProxyOrigins, type CredentialInject, type CredentialSpec } from './credentials';
|
||||
import { getUserCredential, setUserCredentialSecret, userKey, type VaultUser } from './vault';
|
||||
import { declaredUserCredential } from './known-plugins';
|
||||
@@ -278,8 +279,10 @@ async function send(
|
||||
signal: init.signal,
|
||||
...(init.body ? { body: init.body } : {}),
|
||||
};
|
||||
if (trusted) return fetch(url, { ...request, redirect: 'manual' } as RequestInit);
|
||||
return (await fetchPublicUrl(url, request as never)) as unknown as Response;
|
||||
return observeUpstream('plugin_proxy', async () => {
|
||||
if (trusted) return fetch(url, { ...request, redirect: 'manual' } as RequestInit);
|
||||
return (await fetchPublicUrl(url, request as never)) as unknown as Response;
|
||||
});
|
||||
}
|
||||
|
||||
function responseHeaders(upstream: Response): Headers {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* A request path reduced to a fixed, small set of names: the area of the app
|
||||
* or the API folder it belongs to. Error reports use it instead of the URL,
|
||||
* and the request metrics use it as a label, so neither ever carries a folder
|
||||
* name, a message id, a query string or anything else a user typed.
|
||||
*
|
||||
* lib/__tests__/route-groups.test.ts checks API_GROUPS against app/api and
|
||||
* PAGE_AREAS against app/(main)/[locale].
|
||||
*/
|
||||
|
||||
/** The folders under app/api. */
|
||||
export const API_GROUPS: readonly string[] = [
|
||||
'account', 'account-archive', 'admin', 'auth', 'bimi', 'caldav', 'calendar-agenda', 'config',
|
||||
'connector', 'dev-jmap', 'errors', 'favicon', 'fetch-ical', 'health', 'metrics', 'og-image',
|
||||
'plugin-approval-status', 'plugin-proxy', 'plugin-secrets', 'plugin-signing-pubkey', 'plugins',
|
||||
'push', 'pwa-icon', 'pwa-screenshot', 'ready', 'settings', 'setup', 'system', 'translate',
|
||||
'webdav', 'wopi',
|
||||
];
|
||||
|
||||
/** The page areas under app/(main)/[locale], plus the pages outside it. */
|
||||
export const PAGE_AREAS: readonly string[] = [
|
||||
'apps', 'auth', 'calendar', 'contacts', 'files', 'login', 'mail', 'plugins', 'pro', 'settings',
|
||||
'admin', 'setup', 'connector', 'protocol', 'plugin-sandbox', 'plugin-sandbox-privileged',
|
||||
];
|
||||
|
||||
const API = new Set(API_GROUPS);
|
||||
const AREAS = new Set(PAGE_AREAS);
|
||||
const ASSET_FOLDERS = new Set(['branding', 'notification', 'demo', 'plugin-sandbox-assets']);
|
||||
/** en, de, pt-BR, zh-TW, sr-Latn: what a locale prefix looks like. */
|
||||
const LOCALE = /^[a-z]{2,3}(?:-[A-Za-z]{2,4})?$/;
|
||||
|
||||
interface Parsed {
|
||||
group: string;
|
||||
/** Path segments below the group's own. */
|
||||
deeper: boolean;
|
||||
}
|
||||
|
||||
function parse(rawPath: string, basePath: string): Parsed {
|
||||
let path = rawPath.split(/[?#]/, 1)[0] || '/';
|
||||
if (basePath && (path === basePath || path.startsWith(`${basePath}/`))) path = path.slice(basePath.length) || '/';
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
if (segments.length === 0) return { group: 'page:home', deeper: false };
|
||||
const [first, second] = segments;
|
||||
if (first === '_next') {
|
||||
return { group: second === 'static' ? 'static' : second === 'image' ? 'image' : 'other', deeper: false };
|
||||
}
|
||||
if (first === 'api') {
|
||||
return { group: second && API.has(second) ? `api:${second}` : 'api:other', deeper: segments.length > 2 };
|
||||
}
|
||||
if ((segments.length === 1 && first.includes('.')) || ASSET_FOLDERS.has(first)) return { group: 'asset', deeper: false };
|
||||
const rest = LOCALE.test(first) && !AREAS.has(first) ? segments.slice(1) : segments;
|
||||
if (rest.length === 0) return { group: 'page:home', deeper: false };
|
||||
return { group: AREAS.has(rest[0]) ? `page:${rest[0]}` : 'page:other', deeper: rest.length > 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The group of a path: `api:<folder>`, `page:<area>`, `page:home`, `static`,
|
||||
* `image`, `asset` or `other`. `basePath` is removed first.
|
||||
*/
|
||||
export function routeGroup(rawPath: string, basePath = ''): string {
|
||||
return parse(rawPath, basePath).group;
|
||||
}
|
||||
|
||||
/**
|
||||
* A path for an error report: the group as a path, and `/…` when the path
|
||||
* went deeper. `/de/mail/folder/Projects%20ACME/abc` -> `/mail/…`.
|
||||
*/
|
||||
export function scrubRoutePath(rawPath: string, basePath = ''): string {
|
||||
const { group, deeper } = parse(rawPath, basePath);
|
||||
const base = group === 'page:home' ? '/'
|
||||
: group.startsWith('api:') ? `/api/${group.slice(4)}`
|
||||
: group.startsWith('page:') ? `/${group.slice(5)}`
|
||||
: `/${group}`;
|
||||
return deeper ? `${base}/…` : base;
|
||||
}
|
||||
@@ -35,6 +35,8 @@ export const SERVER_STATE_ALLOWLIST: AllowedState[] = [
|
||||
{ site: 'app/api/favicon/route.ts#cache', class: 'cache', why: 'sender icons from a public service, 14 days' },
|
||||
{ site: 'app/api/favicon/route.ts#negativeCache', class: 'cache', why: 'sender icons that failed, 1 day' },
|
||||
{ site: 'app/api/ready/route.ts#last', class: 'cache', why: 'the last store ping, 1 s, so probes can\'t flood the store' },
|
||||
{ site: 'lib/tracing/config.ts#globalThis:ACTIVE', class: 'per-process', why: 'whether this process registered the OpenTelemetry SDK at startup' },
|
||||
{ site: 'lib/error-reporting/debug-ids.ts#cache', class: 'per-process', why: 'debug ids of the bundle files this process loaded, read from globalThis' },
|
||||
{ site: 'lib/admin/directory-keys.ts#keyringCache', class: 'cache', why: 'extension directory keys, 10 min; the anti-rollback record is in the store' },
|
||||
{ site: 'lib/admin/paths.ts#cachedReadOnly', class: 'per-process', why: 'parsed ADMIN_CONFIG_READONLY env' },
|
||||
{ site: 'lib/admin/plugin-approvals.ts#maxPackageBytes', class: 'per-process', why: 'a limit tests can lower' },
|
||||
|
||||
@@ -19,6 +19,7 @@ import { configManager } from '@/lib/admin/config-manager';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||
import { fetchPublicUrl, type PublicFetchResponse } from '@/lib/security/url-guard';
|
||||
import { observeUpstream } from '@/lib/metrics/upstream';
|
||||
import {
|
||||
isStoredCredentialRefused,
|
||||
recordStoredCredentialStatus,
|
||||
@@ -72,7 +73,8 @@ export async function fetchJmapServer(
|
||||
): Promise<Response> {
|
||||
const authHeader = new Headers(givenInit.headers).get('authorization');
|
||||
if (authHeader && (await isStoredCredentialRefused(authHeader))) return refusedCredentialResponse();
|
||||
const response = await fetchUnbudgeted(url, givenInit, trusted);
|
||||
// With tracing on, Stalwart gets the trace context and its spans join ours.
|
||||
const response = await observeUpstream('stalwart', (trace) => fetchUnbudgeted(url, withTraceHeaders(givenInit, trace), trusted));
|
||||
// Not awaited: this process's copy is updated at once, the shared write follows.
|
||||
if (authHeader) {
|
||||
void recordStoredCredentialStatus(authHeader, response.status).catch((error) => {
|
||||
@@ -82,6 +84,13 @@ export async function fetchJmapServer(
|
||||
return response;
|
||||
}
|
||||
|
||||
function withTraceHeaders(init: ServerFetchInit, trace: Record<string, string>): ServerFetchInit {
|
||||
if (Object.keys(trace).length === 0) return init;
|
||||
const headers = new Headers(init.headers);
|
||||
for (const [name, value] of Object.entries(trace)) headers.set(name, value);
|
||||
return { ...init, headers };
|
||||
}
|
||||
|
||||
async function fetchUnbudgeted(
|
||||
url: string,
|
||||
givenInit: ServerFetchInit,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { startScheduler, stopScheduler, reschedule, sendOnce } from './sender';
|
||||
export { buildPayload, markProcessStart } from './payload';
|
||||
export { buildPayload, errorsForDay, errorsForPayload, markProcessStart } from './payload';
|
||||
export {
|
||||
loadState, saveState, updateState, getInstanceId, effectiveConsent,
|
||||
loadState, saveState, updateState, getInstanceId, effectiveConsent, effectiveErrorsConsent,
|
||||
} from './state';
|
||||
export { recordLogin, getLoginCounts } from './login-tracker';
|
||||
export {
|
||||
@@ -9,7 +9,8 @@ export {
|
||||
} from './endpoint-guard';
|
||||
export type { EndpointCheck } from './endpoint-guard';
|
||||
export type {
|
||||
TelemetryPayload, TelemetryStateFile, ConsentState,
|
||||
TelemetryPayload, TelemetryPayloadV2, AnyTelemetryPayload, TelemetryError, TelemetryStateFile, ConsentState,
|
||||
ErrorsConsent, TelemetryErrorBatch,
|
||||
Platform, OsFamily, CountBucket, TelemetryFeatures,
|
||||
} from './types';
|
||||
export { DEFAULT_ENDPOINT } from './types';
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { logger } from '@/lib/logger';
|
||||
import { readErrors, utcDay } from '@/lib/error-reporting/aggregate';
|
||||
import { effectiveConsent, effectiveErrorsConsent, endpointEnabled, getInstanceId } from './state';
|
||||
import { errorsForDay } from './payload';
|
||||
import { fetchTelemetryTarget, resolveEndpointAllowed } from './endpoint-guard';
|
||||
import { DEFAULT_ENDPOINT, errorsEndpointFor, type TelemetryErrorBatch } from './types';
|
||||
|
||||
/**
|
||||
* Instant error counts. With the error-count consent on "instant", the
|
||||
* scheduler leader posts the current UTC day's counts to the collector's
|
||||
* /v1/errors every TICK_MS. Only errors that are new or whose count bucket
|
||||
* changed since the last send go out, so a known error repeating doesn't
|
||||
* cause traffic until it reaches the next bucket. The daily heartbeat still
|
||||
* carries the complete day as before.
|
||||
*
|
||||
* Each tick reads the consent, so switching it in the admin takes effect on
|
||||
* the next tick without restarting anything. Other replicas' counts reach
|
||||
* the store with their own flush (every minute), so they go out a minute later.
|
||||
*/
|
||||
|
||||
const TICK_MS = 60_000;
|
||||
|
||||
interface InstantState {
|
||||
timer: NodeJS.Timeout | null;
|
||||
/** The day `sent` is for. */
|
||||
day: string;
|
||||
/** `fingerprint|version` -> the count bucket last sent. */
|
||||
sent: Map<string, string>;
|
||||
running: Promise<void> | null;
|
||||
}
|
||||
|
||||
// On globalThis, like the heartbeat timer: one per process, whichever
|
||||
// module copy starts or stops it.
|
||||
const KEY = Symbol.for('bulwark.telemetry.instant-errors');
|
||||
function state(): InstantState {
|
||||
const g = globalThis as typeof globalThis & { [KEY]?: InstantState };
|
||||
return (g[KEY] ??= { timer: null, day: '', sent: new Map(), running: null });
|
||||
}
|
||||
|
||||
async function tick(now: number): Promise<void> {
|
||||
const s = state();
|
||||
const { consent } = await effectiveErrorsConsent();
|
||||
if (consent !== 'instant') {
|
||||
s.sent.clear();
|
||||
return;
|
||||
}
|
||||
const { state: stored } = await effectiveConsent();
|
||||
const endpoint = stored.endpoint ?? DEFAULT_ENDPOINT;
|
||||
if (!endpointEnabled(endpoint)) return;
|
||||
let target: string;
|
||||
try {
|
||||
target = errorsEndpointFor(endpoint);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const day = utcDay(now);
|
||||
if (s.day !== day) {
|
||||
s.day = day;
|
||||
s.sent.clear();
|
||||
}
|
||||
const changed = errorsForDay(await readErrors(), day)
|
||||
.filter((error) => s.sent.get(`${error.fingerprint}|${error.version}`) !== error.count);
|
||||
if (changed.length === 0) return;
|
||||
|
||||
// Checked at send time, like the heartbeat: defeats DNS rebinding and a
|
||||
// state file edited past the admin API.
|
||||
const guard = await resolveEndpointAllowed(target);
|
||||
if (!guard.ok) {
|
||||
logger.warn('telemetry: errors endpoint blocked', { reason: guard.reason });
|
||||
return;
|
||||
}
|
||||
|
||||
const batch: TelemetryErrorBatch = { schema: '1', instance_id: await getInstanceId(), day, errors: changed };
|
||||
const res = await fetchTelemetryTarget(target, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(batch),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Not marked as sent: the next tick tries them again.
|
||||
logger.warn('telemetry: instant error counts rejected', { status: res.status });
|
||||
return;
|
||||
}
|
||||
for (const error of changed) s.sent.set(`${error.fingerprint}|${error.version}`, error.count);
|
||||
}
|
||||
|
||||
/** One tick: send what changed, if instant mode is on. Never throws. */
|
||||
export function sendInstantErrors(now = Date.now()): Promise<void> {
|
||||
const s = state();
|
||||
if (s.running) return s.running;
|
||||
s.running = tick(now)
|
||||
.catch((error) => {
|
||||
logger.warn('telemetry: instant error counts failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
})
|
||||
.finally(() => { s.running = null; });
|
||||
return s.running;
|
||||
}
|
||||
|
||||
// Run by the scheduler leader (lib/scheduler-leader.ts). Idempotent.
|
||||
export async function startInstantErrors(): Promise<void> {
|
||||
const s = state();
|
||||
if (s.timer) return;
|
||||
s.timer = setInterval(() => { void sendInstantErrors(); }, TICK_MS);
|
||||
s.timer.unref?.();
|
||||
}
|
||||
|
||||
export async function stopInstantErrors(): Promise<void> {
|
||||
const s = state();
|
||||
if (s.timer) clearInterval(s.timer);
|
||||
s.timer = null;
|
||||
// A new leader starts from nothing and sends the day's counts once.
|
||||
s.sent.clear();
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
export function resetInstantErrorsForTests(): void {
|
||||
const s = state();
|
||||
if (s.timer) clearInterval(s.timer);
|
||||
const g = globalThis as typeof globalThis & { [KEY]?: InstantState };
|
||||
delete g[KEY];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user