From 54af07f2af35937bf2e7168b0af3973ba08540fe Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 28 Apr 2026 01:28:41 +0200 Subject: [PATCH] feat: add anonymous instance telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /.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. --- CHANGELOG.md | 4 +- app/admin/telemetry/page.tsx | 234 ++++++++++++++++++++++++++ app/api/admin/marketplace/route.ts | 2 +- app/api/admin/oauth/setup/route.ts | 4 +- app/api/admin/telemetry/route.ts | 126 ++++++++++++++ components/layout/navigation-rail.tsx | 2 +- instrumentation.node.ts | 7 + lib/admin/csp-frame-origins.ts | 4 +- lib/telemetry/index.ts | 10 ++ lib/telemetry/payload.ts | 146 ++++++++++++++++ lib/telemetry/sender.ts | 99 +++++++++++ lib/telemetry/state.ts | 101 +++++++++++ lib/telemetry/types.ts | 49 ++++++ 13 files changed, 780 insertions(+), 8 deletions(-) create mode 100644 app/admin/telemetry/page.tsx create mode 100644 app/api/admin/telemetry/route.ts create mode 100644 lib/telemetry/index.ts create mode 100644 lib/telemetry/payload.ts create mode 100644 lib/telemetry/sender.ts create mode 100644 lib/telemetry/state.ts create mode 100644 lib/telemetry/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f7e11d1..e4373ed1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/admin/telemetry/page.tsx b/app/admin/telemetry/page.tsx new file mode 100644 index 00000000..7921aee4 --- /dev/null +++ b/app/admin/telemetry/page.tsx @@ -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; +} + +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(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(null); + const [endpointDraft, setEndpointDraft] = useState(''); + const [sendResult, setSendResult] = useState<{ ok: boolean; msg: string } | null>(null); + + async function refresh(): Promise { + 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 { + 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 { + 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 { + 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 ( +
+ loading… +
+ ); + } + + const envOverridden = status.consentSource === 'env'; + const isOn = status.consent === 'on'; + + return ( +
+
+

Anonymous Usage Stats

+

+ Bulwark sends one anonymous heartbeat per day so we can see how many instances are + running, on what platforms, and which features they use. Enabled by default; + one click below disables it. No email addresses, no hostnames, no IPs are sent.{' '} + + Full schema and policy + +

+
+ +
+
+
+
Status
+
+ {status.consent === 'pending' && 'Initialising — no heartbeats sent yet.'} + {status.consent === 'on' && 'Heartbeats are enabled (default).'} + {status.consent === 'off' && 'Heartbeats are off.'} + {envOverridden && ( + <> Locked by BULWARK_TELEMETRY env var. + )} +
+
+
+ + +
+
+
+
Last sent
+
{timeAgo(status.lastSentAt)}
+
Next scheduled
+
{timeAgo(status.nextScheduledAt)}
+
Consented at
+
{status.consentedAt ? new Date(status.consentedAt).toLocaleString() : '-'}
+
+
+ +
+
Endpoint
+

+ Where heartbeats are sent. Defaults to the project's collector. Point at your own collector + (open source at bulwarkmail/dashboard) or clear this field to disable sending. +

+
+ setEndpointDraft(e.target.value)} + placeholder={status.defaultEndpoint} + className="flex-1 px-3 py-1.5 rounded-md border bg-background" + /> + +
+
+ +
+
+
+
Payload preview
+
+ Exactly what the next heartbeat would send from this install, right now. +
+
+ +
+ {sendResult && ( +
+ {sendResult.ok ? : } + {sendResult.msg} +
+ )} +
+          {JSON.stringify(status.payloadPreview, null, 2)}
+        
+
+
+ ); +} diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index fdf25e61..021996f6 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -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) diff --git a/app/api/admin/oauth/setup/route.ts b/app/api/admin/oauth/setup/route.ts index b1c63851..0182e197 100644 --- a/app/api/admin/oauth/setup/route.ts +++ b/app/api/admin/oauth/setup/route.ts @@ -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 }); } diff --git a/app/api/admin/telemetry/route.ts b/app/api/admin/telemetry/route.ts new file mode 100644 index 00000000..a3de8eb1 --- /dev/null +++ b/app/api/admin/telemetry/route.ts @@ -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 }); + } +} diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 4bdccb05..8c9b1163 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -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 && ( { 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); }); diff --git a/lib/admin/csp-frame-origins.ts b/lib/admin/csp-frame-origins.ts index 9fa211ea..910aca17 100644 --- a/lib/admin/csp-frame-origins.ts +++ b/lib/admin/csp-frame-origins.ts @@ -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 { const now = Date.now(); diff --git a/lib/telemetry/index.ts b/lib/telemetry/index.ts new file mode 100644 index 00000000..cec835b8 --- /dev/null +++ b/lib/telemetry/index.ts @@ -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'; diff --git a/lib/telemetry/payload.ts b/lib/telemetry/payload.ts new file mode 100644 index 00000000..c6d4d8f8 --- /dev/null +++ b/lib/telemetry/payload.ts @@ -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 { + 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 { + 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, + }; +} diff --git a/lib/telemetry/sender.ts b/lib/telemetry/sender.ts new file mode 100644 index 00000000..675b3e93 --- /dev/null +++ b/lib/telemetry/sender.ts @@ -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 { + 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 { + await sendOnce({ reason: 'scheduled' }); + await scheduleNext(jitteredDelay(DAY_MS)); +} + +// Called from instrumentation. Idempotent. +export async function startScheduler(): Promise { + 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 { + if (currentTimer) clearTimeout(currentTimer); + currentTimer = null; +} + +// Called when consent flips on/off via the UI. +export async function reschedule(): Promise { + await stopScheduler(); + await startScheduler(); +} diff --git a/lib/telemetry/state.ts b/lib/telemetry/state.ts new file mode 100644 index 00000000..ff94802e --- /dev/null +++ b/lib/telemetry/state.ts @@ -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 { + if (!existsSync(getDir())) await mkdir(getDir(), { recursive: true }); +} + +export async function getInstanceId(): Promise { + 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 { + await ensureDir(); + try { + const raw = await readFile(statePath(), 'utf8'); + const parsed = JSON.parse(raw) as Partial; + 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