feat: add anonymous instance telemetry

Adds a once-per-day heartbeat that lets the project see how many
instances run Bulwark, on what platforms, with what features enabled,
and roughly how many accounts they have. No email addresses, hostnames,
IPs, or any end-user data are ever sent.

- lib/telemetry: state file, payload builder, jittered scheduler,
  instance_id persistence at <data-dir>/.telemetry-id (delete to reset)
- app/api/admin/telemetry: admin API for status / set-consent /
  set-endpoint / send-now (all audit-logged)
- app/admin/telemetry: settings page with status, JSON payload preview,
  endpoint editor, send-now button, link to the privacy page
- instrumentation.node.ts: starts the scheduler on boot

Default state is enabled. The first heartbeat fires 1 hour after boot
so an admin who installs and immediately disables produces zero pings.
Disable via the settings UI, BULWARK_TELEMETRY=off (or
BULWARK_TELEMETRY_DISABLED=1), or by clearing the endpoint.

Account counts are bucketed (1, 2-5, 6-10, 11-50, 51-200, 201+) so a
small instance can't be re-identified by exact size. The /.telemetry-id
file can be deleted to mint a fresh instance_id.

Receiving collector is open source at bulwarkmail/dashboard. Self-host
your own and point at it via BULWARK_TELEMETRY_URL. Full schema,
retention (90d raw → aggregates), and lawful basis are documented at
bulwarkmail.org/docs/legal/privacy/telemetry.
This commit is contained in:
Linus Rath
2026-04-28 01:28:41 +02:00
parent 68f1fabc4b
commit 54af07f2af
13 changed files with 780 additions and 8 deletions
+1 -1
View File
@@ -232,7 +232,7 @@ export async function POST(request: NextRequest) {
// Plugins may declare iframe origins they need for embedded content.
// Anything that doesn't pass strict origin validation is silently
// dropped the plugin still installs, but those origins are not
// dropped - the plugin still installs, but those origins are not
// added to the host CSP.
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
+2 -2
View File
@@ -139,7 +139,7 @@ export async function POST(request: NextRequest) {
const queryEntry = queryRes.methodResponses?.[0];
if (!queryEntry || queryEntry[0] === 'error') {
return NextResponse.json({
error: 'Stalwart denied OAuthClient/query your Stalwart account likely lacks admin permissions.',
error: 'Stalwart denied OAuthClient/query - your Stalwart account likely lacks admin permissions.',
detail: queryEntry?.[1],
}, { status: 403 });
}
@@ -187,7 +187,7 @@ export async function POST(request: NextRequest) {
const setEntry = setRes.methodResponses?.[0];
if (!setEntry || setEntry[0] === 'error') {
return NextResponse.json({
error: 'Stalwart denied OAuthClient/set admin permissions required.',
error: 'Stalwart denied OAuthClient/set - admin permissions required.',
detail: setEntry?.[1],
}, { status: 403 });
}
+126
View File
@@ -0,0 +1,126 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import {
effectiveConsent,
loadState,
saveState,
buildPayload,
sendOnce,
reschedule,
DEFAULT_ENDPOINT,
} from '@/lib/telemetry';
/**
* GET /api/admin/telemetry
* Returns current consent + endpoint + next/last send + a live preview
* of exactly what the next heartbeat would contain.
*/
export async function GET() {
try {
const auth = await requireAdminAuth();
if ('error' in auth) return auth.error;
const { consent, source, state } = await effectiveConsent();
const payload = await buildPayload();
return NextResponse.json(
{
consent,
consentSource: source,
endpoint: state.endpoint || DEFAULT_ENDPOINT,
consentedAt: state.consentedAt,
lastSentAt: state.lastSentAt,
nextScheduledAt: state.nextScheduledAt,
defaultEndpoint: DEFAULT_ENDPOINT,
payloadPreview: payload,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
} catch (err) {
logger.error('telemetry GET error', {
error: err instanceof Error ? err.message : 'unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/admin/telemetry
* Body: { action: 'set-consent' | 'set-endpoint' | 'send-now', ... }
* set-consent : { action, consent: 'on' | 'off' }
* set-endpoint : { action, endpoint: string }
* send-now : { action }
*/
export async function POST(request: NextRequest) {
try {
const auth = await requireAdminAuth();
if ('error' in auth) return auth.error;
const ip = getClientIP(request);
const body = (await request.json().catch(() => null)) as
| { action?: string; consent?: string; endpoint?: string }
| null;
if (!body || typeof body.action !== 'string') {
return NextResponse.json({ error: 'action required' }, { status: 400 });
}
const { source } = await effectiveConsent();
if (body.action === 'set-consent') {
if (source === 'env') {
return NextResponse.json(
{ error: 'consent is overridden by BULWARK_TELEMETRY env var' },
{ status: 409 },
);
}
if (body.consent !== 'on' && body.consent !== 'off') {
return NextResponse.json({ error: 'consent must be "on" or "off"' }, { status: 400 });
}
const state = await loadState();
const before = state.consent;
state.consent = body.consent;
if (body.consent === 'on' && !state.consentedAt) {
state.consentedAt = new Date().toISOString();
}
await saveState(state);
await reschedule();
await auditLog('telemetry.set-consent', { from: before, to: body.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 });
}
const trimmed = body.endpoint.trim();
if (trimmed && !/^https?:\/\//i.test(trimmed)) {
return NextResponse.json({ error: 'endpoint must be http(s)://' }, { status: 400 });
}
const state = await loadState();
const before = state.endpoint;
state.endpoint = trimmed || DEFAULT_ENDPOINT;
await saveState(state);
await auditLog('telemetry.set-endpoint', { from: before, to: state.endpoint }, ip);
return NextResponse.json({ ok: true, endpoint: state.endpoint });
}
if (body.action === 'send-now') {
const result = await sendOnce({ reason: 'admin-manual' });
await auditLog(
'telemetry.send-now',
{ ok: result.ok, status: result.status ?? null, error: result.error ?? null },
ip,
);
return NextResponse.json(result, { status: result.ok ? 200 : 502 });
}
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
} catch (err) {
logger.error('telemetry POST error', {
error: err instanceof Error ? err.message : 'unknown',
});
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}