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:
+2
-2
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
### Features
|
### 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**: 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**: 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
|
- **Calendar/Contacts**: JMAP sharing for calendars and address books
|
||||||
- **i18n**: Czech language support
|
- **i18n**: Czech language support
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -232,7 +232,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// Plugins may declare iframe origins they need for embedded content.
|
// Plugins may declare iframe origins they need for embedded content.
|
||||||
// Anything that doesn't pass strict origin validation is silently
|
// 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.
|
// added to the host CSP.
|
||||||
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||||
const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
|
const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const queryEntry = queryRes.methodResponses?.[0];
|
const queryEntry = queryRes.methodResponses?.[0];
|
||||||
if (!queryEntry || queryEntry[0] === 'error') {
|
if (!queryEntry || queryEntry[0] === 'error') {
|
||||||
return NextResponse.json({
|
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],
|
detail: queryEntry?.[1],
|
||||||
}, { status: 403 });
|
}, { status: 403 });
|
||||||
}
|
}
|
||||||
@@ -187,7 +187,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const setEntry = setRes.methodResponses?.[0];
|
const setEntry = setRes.methodResponses?.[0];
|
||||||
if (!setEntry || setEntry[0] === 'error') {
|
if (!setEntry || setEntry[0] === 'error') {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
error: 'Stalwart denied OAuthClient/set — admin permissions required.',
|
error: 'Stalwart denied OAuthClient/set - admin permissions required.',
|
||||||
detail: setEntry?.[1],
|
detail: setEntry?.[1],
|
||||||
}, { status: 403 });
|
}, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 && (
|
{isStalwartAdmin && (
|
||||||
<a
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ configManager.load()
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
console.info("Admin dashboard initialized");
|
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) => {
|
.catch((err) => {
|
||||||
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
|
console.warn("Admin dashboard init skipped:", err instanceof Error ? err.message : err);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* domains in the host CSP.
|
* domains in the host CSP.
|
||||||
*
|
*
|
||||||
* Origins are validated at install time and re-validated here as defense in
|
* 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.
|
* inject arbitrary CSP fragments.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ const CACHE_TTL_MS = 5_000;
|
|||||||
* the server-side registry, deduped and validated.
|
* the server-side registry, deduped and validated.
|
||||||
*
|
*
|
||||||
* Returns an empty array on any failure (missing file, parse error, …) so
|
* 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[]> {
|
export async function getEnabledPluginFrameOrigins(): Promise<string[]> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
Reference in New Issue
Block a user