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
+2 -2
View File
@@ -4,8 +4,8 @@
### Features
- **Plugins**: New `composer-sidebar` slot and `ui:composer-sidebar` permission plugins can now render a panel on either side of the New Message dialog. See `repos/subway-surfers` for an example
- **Plugins**: Manifests can declare `frameOrigins` a strictly-validated list of `https://host` origins the plugin needs to embed. The proxy reads the union from enabled plugins and merges it into the host CSP `frame-src`, so the host CSP no longer needs to know about specific embed providers
- **Plugins**: New `composer-sidebar` slot and `ui:composer-sidebar` permission - plugins can now render a panel on either side of the New Message dialog. See `repos/subway-surfers` for an example
- **Plugins**: Manifests can declare `frameOrigins` - a strictly-validated list of `https://host` origins the plugin needs to embed. The proxy reads the union from enabled plugins and merges it into the host CSP `frame-src`, so the host CSP no longer needs to know about specific embed providers
- **Calendar/Contacts**: JMAP sharing for calendars and address books
- **i18n**: Czech language support
+234
View File
@@ -0,0 +1,234 @@
'use client';
import { useEffect, useState } from 'react';
import { Loader2, Send, Save, CheckCircle2, XCircle, ExternalLink } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation';
interface TelemetryStatus {
consent: 'pending' | 'on' | 'off';
consentSource: 'env' | 'file';
endpoint: string;
defaultEndpoint: string;
consentedAt: string | null;
lastSentAt: string | null;
nextScheduledAt: string | null;
payloadPreview: Record<string, unknown>;
}
function timeAgo(iso: string | null): string {
if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime();
if (d < 0) return new Date(iso).toLocaleString();
const m = Math.floor(d / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m} min ago`;
const h = Math.floor(m / 60);
if (h < 48) return `${h} hours ago`;
const days = Math.floor(h / 24);
return `${days} days ago`;
}
export default function AdminTelemetryPage() {
const [status, setStatus] = useState<TelemetryStatus | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [endpointDraft, setEndpointDraft] = useState('');
const [sendResult, setSendResult] = useState<{ ok: boolean; msg: string } | null>(null);
async function refresh(): Promise<void> {
setLoading(true);
try {
const r = await apiFetch('/api/admin/telemetry');
if (!r.ok) throw new Error('failed to load');
const data = (await r.json()) as TelemetryStatus;
setStatus(data);
setEndpointDraft(data.endpoint);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
useEffect(() => { void refresh(); }, []);
async function setConsent(consent: 'on' | 'off'): Promise<void> {
setBusy('consent');
try {
const r = await apiFetch('/api/admin/telemetry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'set-consent', consent }),
});
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
alert(j.error ?? 'failed');
}
await refresh();
} finally { setBusy(null); }
}
async function saveEndpoint(): Promise<void> {
setBusy('endpoint');
try {
const r = await apiFetch('/api/admin/telemetry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'set-endpoint', endpoint: endpointDraft }),
});
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
alert(j.error ?? 'failed');
}
await refresh();
} finally { setBusy(null); }
}
async function sendNow(): Promise<void> {
setBusy('send');
setSendResult(null);
try {
const r = await apiFetch('/api/admin/telemetry', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'send-now' }),
});
const j = (await r.json().catch(() => ({}))) as { ok?: boolean; status?: number; error?: string };
setSendResult({
ok: !!j.ok,
msg: j.ok ? `sent (HTTP ${j.status ?? '?'})` : `failed: ${j.error ?? 'unknown'}`,
});
await refresh();
} finally { setBusy(null); }
}
if (loading || !status) {
return (
<div className="p-8 flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> loading
</div>
);
}
const envOverridden = status.consentSource === 'env';
const isOn = status.consent === 'on';
return (
<div className="max-w-3xl mx-auto p-6 space-y-6">
<header className="space-y-2">
<h1 className="text-2xl font-semibold">Anonymous Usage Stats</h1>
<p className="text-sm text-muted-foreground">
Bulwark sends one anonymous heartbeat per day so we can see how many instances are
running, on what platforms, and which features they use. <strong>Enabled by default</strong>;
one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '}
<a
href="https://bulwarkmail.org/docs/legal/privacy/telemetry"
target="_blank"
rel="noreferrer"
className="underline inline-flex items-center gap-1"
>
Full schema and policy <ExternalLink className="h-3 w-3" />
</a>
</p>
</header>
<section className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="font-medium">Status</div>
<div className="text-sm text-muted-foreground">
{status.consent === 'pending' && 'Initialising — no heartbeats sent yet.'}
{status.consent === 'on' && 'Heartbeats are enabled (default).'}
{status.consent === 'off' && 'Heartbeats are off.'}
{envOverridden && (
<> Locked by <code>BULWARK_TELEMETRY</code> env var.</>
)}
</div>
</div>
<div className="flex gap-2">
<button
type="button"
disabled={busy === 'consent' || envOverridden || isOn}
onClick={() => void setConsent('on')}
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Enable
</button>
<button
type="button"
disabled={busy === 'consent' || envOverridden || status.consent === 'off'}
onClick={() => void setConsent('off')}
className="px-3 py-1.5 rounded-md border hover:bg-accent disabled:opacity-50"
>
Disable
</button>
</div>
</div>
<dl className="grid grid-cols-2 gap-2 text-sm pt-2 border-t">
<dt className="text-muted-foreground">Last sent</dt>
<dd>{timeAgo(status.lastSentAt)}</dd>
<dt className="text-muted-foreground">Next scheduled</dt>
<dd>{timeAgo(status.nextScheduledAt)}</dd>
<dt className="text-muted-foreground">Consented at</dt>
<dd>{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}</dd>
</dl>
</section>
<section className="rounded-lg border p-4 space-y-3">
<div className="font-medium">Endpoint</div>
<p className="text-sm text-muted-foreground">
Where heartbeats are sent. Defaults to the project's collector. Point at your own collector
(open source at <code>bulwarkmail/dashboard</code>) or clear this field to disable sending.
</p>
<div className="flex gap-2">
<input
type="url"
value={endpointDraft}
onChange={(e) => setEndpointDraft(e.target.value)}
placeholder={status.defaultEndpoint}
className="flex-1 px-3 py-1.5 rounded-md border bg-background"
/>
<button
type="button"
disabled={busy === 'endpoint' || endpointDraft === status.endpoint}
onClick={() => void saveEndpoint()}
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
>
<Save className="h-4 w-4" /> Save
</button>
</div>
</section>
<section className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="font-medium">Payload preview</div>
<div className="text-sm text-muted-foreground">
Exactly what the next heartbeat would send from this install, right now.
</div>
</div>
<button
type="button"
disabled={busy === 'send' || !isOn}
onClick={() => void sendNow()}
className="px-3 py-1.5 rounded-md border bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 inline-flex items-center gap-1"
>
<Send className="h-4 w-4" /> Send now
</button>
</div>
{sendResult && (
<div
className={`text-sm flex items-center gap-2 ${
sendResult.ok ? 'text-emerald-600' : 'text-red-600'
}`}
>
{sendResult.ok ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
{sendResult.msg}
</div>
)}
<pre className="text-xs bg-muted/50 rounded-md p-3 overflow-x-auto max-h-96">
{JSON.stringify(status.payloadPreview, null, 2)}
</pre>
</section>
</div>
);
}
+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 });
}
}
+1 -1
View File
@@ -335,7 +335,7 @@ export function NavigationRail({
);
})}
{/* Admin (Stalwart admins) hard nav because /admin lives outside the [locale] tree */}
{/* Admin (Stalwart admins) - hard nav because /admin lives outside the [locale] tree */}
{isStalwartAdmin && (
<a
href="/admin"
+7
View File
@@ -51,6 +51,13 @@ configManager.load()
.then(() => {
console.info("Admin dashboard initialized");
})
.then(async () => {
// Anonymous telemetry - opt-in, off until admin consents.
// See https://bulwarkmail.org/docs/legal/privacy/telemetry
const { startScheduler, markProcessStart } = await import("./lib/telemetry");
markProcessStart();
await startScheduler();
})
.catch((err) => {
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
});
+2 -2
View File
@@ -5,7 +5,7 @@
* domains in the host CSP.
*
* Origins are validated at install time and re-validated here as defense in
* depth any malformed value is dropped so a corrupted registry can never
* depth - any malformed value is dropped so a corrupted registry can never
* inject arbitrary CSP fragments.
*/
@@ -65,7 +65,7 @@ const CACHE_TTL_MS = 5_000;
* the server-side registry, deduped and validated.
*
* Returns an empty array on any failure (missing file, parse error, …) so
* a broken registry only ever shrinks the CSP never widens it.
* a broken registry only ever shrinks the CSP - never widens it.
*/
export async function getEnabledPluginFrameOrigins(): Promise<string[]> {
const now = Date.now();
+10
View File
@@ -0,0 +1,10 @@
export { startScheduler, stopScheduler, reschedule, sendOnce } from './sender';
export { buildPayload, markProcessStart } from './payload';
export {
loadState, saveState, getInstanceId, effectiveConsent,
} from './state';
export type {
TelemetryPayload, TelemetryStateFile, ConsentState,
Platform, OsFamily, CountBucket, TelemetryFeatures,
} from './types';
export { DEFAULT_ENDPOINT } from './types';
+146
View File
@@ -0,0 +1,146 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger';
import { getInstanceId } from './state';
import type {
TelemetryPayload,
TelemetryFeatures,
Platform,
OsFamily,
CountBucket,
} from './types';
let processStartedAt = Date.now();
export function markProcessStart(): void {
processStartedAt = Date.now();
}
function readPackage(): { version: string; build: string | null } {
try {
const pkg = JSON.parse(
readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'),
) as { version?: string };
return { version: pkg.version ?? '0.0.0', build: process.env.BULWARK_BUILD ?? 'release' };
} catch {
return { version: '0.0.0', build: null };
}
}
function detectPlatform(): Platform {
if (process.env.KUBERNETES_SERVICE_HOST) return 'k8s';
// /.dockerenv is the standard Docker container marker.
try {
readFileSync('/.dockerenv');
return 'docker';
} catch { /* not in docker */ }
return 'bare';
}
function detectOs(): OsFamily {
switch (process.platform) {
case 'linux': return 'linux';
case 'darwin': return 'darwin';
case 'win32': return 'windows';
default: return 'unknown';
}
}
export function bucketCount(n: number): CountBucket {
if (n <= 0) return '0';
if (n === 1) return '1';
if (n <= 5) return '2-5';
if (n <= 10) return '6-10';
if (n <= 50) return '11-50';
if (n <= 200) return '51-200';
return '201+';
}
async function readFeatures(): Promise<TelemetryFeatures> {
await configManager.ensureLoaded();
const policy = configManager.getPolicy();
const gates = policy.features ?? {};
const cfg = configManager.getAll();
return {
// Booleans only. We read whether a feature is enabled - never any
// config value beyond a presence check.
calendar: gates.calendarTasksEnabled !== false,
contacts: true,
files: gates.filesEnabled === true,
extensions: gates.pluginsEnabled !== false,
push_relay: !!cfg['pushRelayUrl'],
oauth_enabled: !!cfg['oauthClientId'],
smime_enabled: gates.smimeEnabled === true,
webdav_enabled: gates.filesEnabled === true,
};
}
async function countAccounts(): Promise<{ total: number; active7d: number }> {
// Best-effort. If Stalwart's admin endpoint isn't reachable from here we
// return 0 / 0 - the heartbeat still fires.
try {
const adminUrl = process.env.STALWART_MGMT_URL || process.env.STALWART_ADMIN_URL;
const adminUser = process.env.STALWART_ADMIN_USER;
const adminPass = process.env.STALWART_ADMIN_PASSWORD;
if (!adminUrl || !adminUser || !adminPass) return { total: 0, active7d: 0 };
const auth = Buffer.from(`${adminUser}:${adminPass}`).toString('base64');
const res = await fetch(`${adminUrl.replace(/\/$/, '')}/api/principal?type=individual`, {
headers: { authorization: `Basic ${auth}` },
signal: AbortSignal.timeout(2000),
});
if (!res.ok) return { total: 0, active7d: 0 };
const body = await res.json() as { data?: { total?: number } };
const total = Number(body?.data?.total ?? 0);
return { total, active7d: total };
} catch (err) {
logger.debug?.('telemetry: account count probe failed', {
error: err instanceof Error ? err.message : String(err),
});
return { total: 0, active7d: 0 };
}
}
async function countExtensions(): Promise<{ extensions: number; themes: number }> {
try {
const { getPluginRegistry, getThemeRegistry } = await import('@/lib/admin/plugin-registry');
const [plugins, themes] = await Promise.all([getPluginRegistry(), getThemeRegistry()]);
return {
extensions: plugins.plugins.length,
themes: themes.themes.length,
};
} catch {
return { extensions: 0, themes: 0 };
}
}
export async function buildPayload(): Promise<TelemetryPayload> {
const instance_id = await getInstanceId();
const { version, build } = readPackage();
const features = await readFeatures();
const accounts = await countAccounts();
const exts = await countExtensions();
const uptime_days = Math.min(
365,
Math.floor((Date.now() - processStartedAt) / 86_400_000),
);
return {
schema: '1',
instance_id,
ts: new Date().toISOString(),
version,
build,
platform: detectPlatform(),
node_version: process.versions.node,
os_family: detectOs(),
stalwart_version: process.env.STALWART_VERSION ?? null,
features,
counts: {
accounts: bucketCount(accounts.total),
accounts_active_7d: bucketCount(accounts.active7d),
extensions_installed: exts.extensions,
themes_installed: exts.themes,
},
uptime_days,
};
}
+99
View File
@@ -0,0 +1,99 @@
import { logger } from '@/lib/logger';
import { effectiveConsent, endpointEnabled, loadState, saveState } from './state';
import { buildPayload } from './payload';
import { DEFAULT_ENDPOINT } from './types';
const DAY_MS = 24 * 60 * 60 * 1000;
const JITTER_MS = 2 * 60 * 60 * 1000; // ± 2 hours
const FIRST_DELAY_MS = 60 * 60 * 1000; // 1 hour after consent
let currentTimer: NodeJS.Timeout | null = null;
function jitteredDelay(base: number): number {
const j = (Math.random() * 2 - 1) * JITTER_MS;
return Math.max(60_000, base + j);
}
export async function sendOnce(opts?: { reason?: string }): Promise<{
ok: boolean;
status?: number;
error?: string;
}> {
const { consent, source, state } = await effectiveConsent();
if (consent !== 'on') return { ok: false, error: `consent ${consent} (source ${source})` };
const endpoint = state.endpoint || DEFAULT_ENDPOINT;
if (!endpointEnabled(endpoint)) return { ok: false, error: 'endpoint blank' };
const payload = await buildPayload();
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(5000),
});
const ok = res.ok;
if (ok) {
const next = await loadState();
next.lastSentAt = new Date().toISOString();
await saveState(next);
}
logger.info('telemetry: heartbeat', {
ok, status: res.status, reason: opts?.reason ?? 'scheduled',
});
return { ok, status: res.status };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn('telemetry: heartbeat failed', { error: msg });
return { ok: false, error: msg };
}
}
async function scheduleNext(delayMs: number): Promise<void> {
if (currentTimer) clearTimeout(currentTimer);
const at = new Date(Date.now() + delayMs).toISOString();
const state = await loadState();
state.nextScheduledAt = at;
await saveState(state);
currentTimer = setTimeout(() => { void tick(); }, delayMs);
// Don't keep the process alive just for this.
currentTimer.unref?.();
}
async function tick(): Promise<void> {
await sendOnce({ reason: 'scheduled' });
await scheduleNext(jitteredDelay(DAY_MS));
}
// Called from instrumentation. Idempotent.
export async function startScheduler(): Promise<void> {
const { consent } = await effectiveConsent();
if (consent !== 'on') {
logger.info('telemetry: scheduler not started', { consent });
return;
}
const state = await loadState();
// If we have a next-scheduled time in the future use it; otherwise schedule
// FIRST_DELAY_MS out. This means after a restart we don't fire immediately.
let delay = FIRST_DELAY_MS;
if (state.nextScheduledAt) {
const remaining = new Date(state.nextScheduledAt).getTime() - Date.now();
if (remaining > 0) delay = Math.min(remaining, DAY_MS + JITTER_MS);
}
await scheduleNext(delay);
logger.info('telemetry: scheduler started', {
nextInMs: delay,
endpoint: state.endpoint,
});
}
export async function stopScheduler(): Promise<void> {
if (currentTimer) clearTimeout(currentTimer);
currentTimer = null;
}
// Called when consent flips on/off via the UI.
export async function reschedule(): Promise<void> {
await stopScheduler();
await startScheduler();
}
+101
View File
@@ -0,0 +1,101 @@
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { logger } from '@/lib/logger';
import type { TelemetryStateFile, ConsentState } from './types';
import { DEFAULT_ENDPOINT } from './types';
function getDir(): string {
return process.env.TELEMETRY_DATA_DIR ||
path.join(process.cwd(), 'data', 'telemetry');
}
function statePath(): string { return path.join(getDir(), 'state.json'); }
function idPath(): string { return path.join(getDir(), '.telemetry-id'); }
function envOverride(): ConsentState | null {
const v = (process.env.BULWARK_TELEMETRY ?? '').toLowerCase();
if (v === 'off' || v === 'false' || v === '0' || v === 'no') return 'off';
if (process.env.BULWARK_TELEMETRY_DISABLED) {
const d = process.env.BULWARK_TELEMETRY_DISABLED.toLowerCase();
if (d === '1' || d === 'true' || d === 'yes') return 'off';
}
return null;
}
export async function ensureDir(): Promise<void> {
if (!existsSync(getDir())) await mkdir(getDir(), { recursive: true });
}
export async function getInstanceId(): Promise<string> {
await ensureDir();
try {
const id = (await readFile(idPath(), 'utf8')).trim();
if (/^[0-9a-f-]{36}$/i.test(id)) return id;
} catch { /* generate fresh */ }
const fresh = randomUUID();
const tmp = idPath() + '.tmp';
await writeFile(tmp, fresh, 'utf8');
await rename(tmp, idPath());
return fresh;
}
// Default consent is 'on' — telemetry is anonymous and enabled by default.
// Admins can disable via the UI, the BULWARK_TELEMETRY env var, or by clearing
// the endpoint. See https://bulwarkmail.org/docs/legal/privacy/telemetry.
const DEFAULTS: TelemetryStateFile = {
consent: 'on',
endpoint: DEFAULT_ENDPOINT,
consentedAt: null,
lastSentAt: null,
nextScheduledAt: null,
};
export async function loadState(): Promise<TelemetryStateFile> {
await ensureDir();
try {
const raw = await readFile(statePath(), 'utf8');
const parsed = JSON.parse(raw) as Partial<TelemetryStateFile>;
return { ...DEFAULTS, ...parsed };
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.warn('telemetry: state read failed', {
error: err instanceof Error ? err.message : String(err),
});
}
// First-ever load on a fresh install: persist the default-on state with
// an autoEnabledAt stamp so the admin UI can show "telemetry was
// auto-enabled at <time>; disable here" without re-arming on restart.
const fresh: TelemetryStateFile = {
...DEFAULTS,
consentedAt: new Date().toISOString(),
};
await saveState(fresh);
return fresh;
}
}
export async function saveState(state: TelemetryStateFile): Promise<void> {
await ensureDir();
const tmp = statePath() + '.tmp';
await writeFile(tmp, JSON.stringify(state, null, 2), 'utf8');
await rename(tmp, statePath());
}
// Effective consent: env var wins over file. UI changes are blocked
// when env override is active so the user knows where it's coming from.
export async function effectiveConsent(): Promise<{
consent: ConsentState;
source: 'env' | 'file';
state: TelemetryStateFile;
}> {
const envState = envOverride();
const state = await loadState();
if (envState) return { consent: envState, source: 'env', state };
return { consent: state.consent, source: 'file', state };
}
export function endpointEnabled(endpoint: string | undefined): boolean {
return !!endpoint && endpoint.trim().length > 0;
}
+49
View File
@@ -0,0 +1,49 @@
// Schema v1 of the anonymous heartbeat. Documented at
// https://bulwarkmail.org/docs/legal/privacy/telemetry
export type ConsentState = 'pending' | 'on' | 'off';
export type Platform = 'docker' | 'bare' | 'k8s' | 'unknown';
export type OsFamily = 'linux' | 'darwin' | 'windows' | 'unknown';
export type CountBucket = '0' | '1' | '2-5' | '6-10' | '11-50' | '51-200' | '201+';
export interface TelemetryFeatures {
calendar: boolean;
contacts: boolean;
files: boolean;
extensions: boolean;
push_relay: boolean;
oauth_enabled: boolean;
smime_enabled: boolean;
webdav_enabled: boolean;
}
export interface TelemetryPayload {
schema: '1';
instance_id: string;
ts: string;
version: string;
build: string | null;
platform: Platform;
node_version: string;
os_family: OsFamily;
stalwart_version: string | null;
features: TelemetryFeatures;
counts: {
accounts: CountBucket;
accounts_active_7d: CountBucket;
extensions_installed: number;
themes_installed: number;
};
uptime_days: number;
}
export interface TelemetryStateFile {
consent: ConsentState;
endpoint: string;
consentedAt: string | null;
lastSentAt: string | null;
nextScheduledAt: string | null;
}
export const DEFAULT_ENDPOINT = 'https://telemetry.bulwarkmail.org/v1/heartbeat';