From dafc8ace3c786d7e63a6a3679230245508693c0d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 28 Apr 2026 08:19:46 +0200 Subject: [PATCH] feat: track unique logins --- app/admin/page.tsx | 20 +++- app/admin/telemetry/page.tsx | 16 +++ app/api/admin/telemetry/route.ts | 7 +- app/api/auth/session/route.ts | 3 + app/api/auth/stalwart-context/route.ts | 3 + app/api/auth/totp-token-exchange/route.ts | 8 +- lib/telemetry/index.ts | 1 + lib/telemetry/login-tracker.ts | 125 ++++++++++++++++++++++ lib/telemetry/payload.ts | 32 ++---- 9 files changed, 186 insertions(+), 29 deletions(-) create mode 100644 lib/telemetry/login-tracker.ts diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 5fb6d75e..98eb9c81 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -31,6 +31,7 @@ export default function AdminDashboardPage() { const [pluginCount, setPluginCount] = useState(0); const [themeCount, setThemeCount] = useState(0); const [policyRuleCount, setPolicyRuleCount] = useState(0); + const [accountCounts, setAccountCounts] = useState<{ total: number; active7d: number } | null>(null); const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown'); useEffect(() => { @@ -38,7 +39,7 @@ export default function AdminDashboardPage() { }, []); async function fetchDashboardData() { - const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes] = await Promise.all([ + const [statusRes, auditRes, configRes, adminConfigRes, pluginRes, themeRes, policyRes, telemetryRes] = await Promise.all([ apiFetch('/api/admin/auth'), apiFetch('/api/admin/audit?limit=10'), apiFetch('/api/config'), @@ -46,6 +47,7 @@ export default function AdminDashboardPage() { apiFetch('/api/admin/plugins').catch(() => null), apiFetch('/api/admin/themes').catch(() => null), apiFetch('/api/admin/policy').catch(() => null), + apiFetch('/api/admin/telemetry').catch(() => null), ]); if (statusRes.ok) setStatus(await statusRes.json()); @@ -73,6 +75,12 @@ export default function AdminDashboardPage() { const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0; setPolicyRuleCount(restrictionCount + disabledGates); } + if (telemetryRes?.ok) { + const telemetry = await telemetryRes.json(); + if (telemetry.accountCounts && typeof telemetry.accountCounts.total === 'number') { + setAccountCounts(telemetry.accountCounts); + } + } if (configData?.jmapServerUrl) { try { @@ -165,6 +173,16 @@ export default function AdminDashboardPage() { + {/* Accounts */} + + + {accountCounts?.total ?? '-'} + + + {accountCounts?.active7d ?? '-'} + + + {/* Extensions */} diff --git a/app/admin/telemetry/page.tsx b/app/admin/telemetry/page.tsx index 7921aee4..29ddd72c 100644 --- a/app/admin/telemetry/page.tsx +++ b/app/admin/telemetry/page.tsx @@ -13,6 +13,7 @@ interface TelemetryStatus { lastSentAt: string | null; nextScheduledAt: string | null; payloadPreview: Record; + accountCounts: { total: number; active7d: number }; } function timeAgo(iso: string | null): string { @@ -173,6 +174,21 @@ export default function AdminTelemetryPage() { +
+
Account activity
+

+ Unique accounts that have logged in over the last 90 days. Identities are stored as a + per-instance HMAC, never as plaintext usernames. These are the numbers reported in the + heartbeat as bucketed ranges. +

+
+
Total (90d)
+
{status.accountCounts?.total ?? 0}
+
Active (7d)
+
{status.accountCounts?.active7d ?? 0}
+
+
+
Endpoint

diff --git a/app/api/admin/telemetry/route.ts b/app/api/admin/telemetry/route.ts index a3de8eb1..2bbab5af 100644 --- a/app/api/admin/telemetry/route.ts +++ b/app/api/admin/telemetry/route.ts @@ -10,6 +10,7 @@ import { sendOnce, reschedule, DEFAULT_ENDPOINT, + getLoginCounts, } from '@/lib/telemetry'; /** @@ -23,7 +24,10 @@ export async function GET() { if ('error' in auth) return auth.error; const { consent, source, state } = await effectiveConsent(); - const payload = await buildPayload(); + const [payload, accountCounts] = await Promise.all([ + buildPayload(), + getLoginCounts(), + ]); return NextResponse.json( { @@ -35,6 +39,7 @@ export async function GET() { nextScheduledAt: state.nextScheduledAt, defaultEndpoint: DEFAULT_ENDPOINT, payloadPreview: payload, + accountCounts, }, { headers: { 'Cache-Control': 'no-store' } }, ); diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index e9f9a7cf..0fbd07f7 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -10,6 +10,7 @@ import { setStalwartAuthContextInStore, } from '@/lib/stalwart/auth-context'; import { configManager } from '@/lib/admin/config-manager'; +import { recordLogin } from '@/lib/telemetry/login-tracker'; const COOKIE_OPTIONS = { ...getCookieOptions(), @@ -50,6 +51,8 @@ export async function POST(request: NextRequest) { authHeader, }); + void recordLogin(username, normalizedServerUrl); + return NextResponse.json({ ok: true }); } catch (error) { if (error instanceof JmapAuthVerificationError) { diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts index 89894e2c..138cacc2 100644 --- a/app/api/auth/stalwart-context/route.ts +++ b/app/api/auth/stalwart-context/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth'; import { setStalwartAuthContext } from '@/lib/stalwart/auth-context'; +import { recordLogin } from '@/lib/telemetry/login-tracker'; function getSlot(request: NextRequest, bodySlot: unknown): number { if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) { @@ -32,6 +33,8 @@ export async function POST(request: NextRequest) { authHeader, }); + void recordLogin(username, normalizedServerUrl); + return NextResponse.json({ ok: true }); } catch (error) { if (error instanceof JmapAuthVerificationError) { diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts index 4cede47a..f311faec 100644 --- a/app/api/auth/totp-token-exchange/route.ts +++ b/app/api/auth/totp-token-exchange/route.ts @@ -7,6 +7,7 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { readFileEnv } from '@/lib/read-file-env'; import { configManager } from '@/lib/admin/config-manager'; import { isPublicHttpUrl } from '@/lib/security/url-guard'; +import { recordLogin } from '@/lib/telemetry/login-tracker'; /** * Exchange basic auth credentials (with TOTP appended) for OAuth tokens. @@ -117,7 +118,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 }); } - return await attemptAllStrategies(tokenEndpoint, username, password, slot); + return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot); } catch (error) { logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); @@ -126,6 +127,7 @@ export async function POST(request: NextRequest) { async function attemptAllStrategies( tokenEndpoint: string, + serverUrl: string, username: string, password: string, slot: number, @@ -144,6 +146,7 @@ async function attemptAllStrategies( const result = await tryTokenRequest(tokenEndpoint, params); if (result.ok) { logger.info('TOTP token exchange succeeded (ROPC with client_id)'); + void recordLogin(username, serverUrl); return await storeAndRespond(result.tokens, slot); } attempts.push({ strategy: 'ROPC with client_id', error: result.error }); @@ -155,6 +158,7 @@ async function attemptAllStrategies( const result = await tryTokenRequest(tokenEndpoint, params); if (result.ok) { logger.info('TOTP token exchange succeeded (ROPC without client_id)'); + void recordLogin(username, serverUrl); return await storeAndRespond(result.tokens, slot); } attempts.push({ strategy: 'ROPC without client_id', error: result.error }); @@ -166,6 +170,7 @@ async function attemptAllStrategies( const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth }); if (result.ok) { logger.info('TOTP token exchange succeeded (Basic Auth header)'); + void recordLogin(username, serverUrl); return await storeAndRespond(result.tokens, slot); } attempts.push({ strategy: 'Basic Auth header', error: result.error }); @@ -177,6 +182,7 @@ async function attemptAllStrategies( const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth }); if (result.ok) { logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)'); + void recordLogin(username, serverUrl); return await storeAndRespond(result.tokens, slot); } attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error }); diff --git a/lib/telemetry/index.ts b/lib/telemetry/index.ts index cec835b8..bdc8ae11 100644 --- a/lib/telemetry/index.ts +++ b/lib/telemetry/index.ts @@ -3,6 +3,7 @@ export { buildPayload, markProcessStart } from './payload'; export { loadState, saveState, getInstanceId, effectiveConsent, } from './state'; +export { recordLogin, getLoginCounts } from './login-tracker'; export type { TelemetryPayload, TelemetryStateFile, ConsentState, Platform, OsFamily, CountBucket, TelemetryFeatures, diff --git a/lib/telemetry/login-tracker.ts b/lib/telemetry/login-tracker.ts new file mode 100644 index 00000000..3534ca36 --- /dev/null +++ b/lib/telemetry/login-tracker.ts @@ -0,0 +1,125 @@ +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { createHmac } from 'node:crypto'; +import { logger } from '@/lib/logger'; +import { getInstanceId } from './state'; + +// We never store usernames or server URLs in the clear. Each login is +// recorded as HMAC-SHA256(username + '@' + serverUrl, instance_id), so the +// file on disk cannot be cross-correlated with any other instance and is +// not PII even if leaked. + +interface LoginRecord { + id: string; + lastLoginAt: string; +} + +interface LoginsFile { + records: LoginRecord[]; +} + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; +const RETENTION_MS = 90 * 24 * 60 * 60 * 1000; + +let cache: LoginsFile | null = null; + +function getDir(): string { + return process.env.TELEMETRY_DATA_DIR || path.join(process.cwd(), 'data', 'telemetry'); +} + +function loginsPath(): string { + return path.join(getDir(), 'logins.json'); +} + +async function ensureDir(): Promise { + const dir = getDir(); + if (!existsSync(dir)) await mkdir(dir, { recursive: true }); +} + +async function loadFile(): Promise { + if (cache) return cache; + try { + const raw = await readFile(loginsPath(), 'utf8'); + const parsed = JSON.parse(raw) as Partial; + cache = Array.isArray(parsed?.records) ? { records: parsed.records as LoginRecord[] } : { records: [] }; + } catch { + cache = { records: [] }; + } + return cache; +} + +async function saveFile(file: LoginsFile): Promise { + await ensureDir(); + cache = file; + const tmp = loginsPath() + '.tmp'; + await writeFile(tmp, JSON.stringify(file), 'utf8'); + await rename(tmp, loginsPath()); +} + +function normalizeServer(serverUrl: string): string { + return serverUrl.trim().replace(/\/+$/, '').toLowerCase(); +} + +async function hashIdentity(username: string, serverUrl: string): Promise { + const instanceId = await getInstanceId(); + const subject = `${username.trim().toLowerCase()}@${normalizeServer(serverUrl)}`; + return createHmac('sha256', instanceId).update(subject).digest('hex').slice(0, 32); +} + +/** + * Record a successful login. Best-effort; never throws. Updates the + * existing record's timestamp if the same identity has logged in before, + * otherwise appends a new record. Records older than the retention window + * are pruned on every write. + */ +export async function recordLogin(username: string, serverUrl: string): Promise { + if (!username || !serverUrl) return; + try { + const id = await hashIdentity(username, serverUrl); + const file = await loadFile(); + const now = new Date().toISOString(); + const cutoff = Date.now() - RETENTION_MS; + const next: LoginRecord[] = []; + let updated = false; + for (const rec of file.records) { + const ts = new Date(rec.lastLoginAt).getTime(); + if (Number.isNaN(ts) || ts < cutoff) continue; + if (rec.id === id) { + next.push({ id, lastLoginAt: now }); + updated = true; + } else { + next.push(rec); + } + } + if (!updated) next.push({ id, lastLoginAt: now }); + await saveFile({ records: next }); + } catch (err) { + logger.debug?.('telemetry: recordLogin failed', { + error: err instanceof Error ? err.message : String(err), + }); + } +} + +/** + * Total distinct accounts seen in the 90-day retention window, plus those + * with a login in the last 7 days. + */ +export async function getLoginCounts(): Promise<{ total: number; active7d: number }> { + try { + const file = await loadFile(); + const cutoff = Date.now() - RETENTION_MS; + const sevenAgo = Date.now() - SEVEN_DAYS_MS; + let total = 0; + let active7d = 0; + for (const rec of file.records) { + const ts = new Date(rec.lastLoginAt).getTime(); + if (Number.isNaN(ts) || ts < cutoff) continue; + total++; + if (ts >= sevenAgo) active7d++; + } + return { total, active7d }; + } catch { + return { total: 0, active7d: 0 }; + } +} diff --git a/lib/telemetry/payload.ts b/lib/telemetry/payload.ts index c6d4d8f8..d3f8e2e8 100644 --- a/lib/telemetry/payload.ts +++ b/lib/telemetry/payload.ts @@ -1,8 +1,8 @@ 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 { getLoginCounts } from './login-tracker'; import type { TelemetryPayload, TelemetryFeatures, @@ -75,30 +75,10 @@ async function readFeatures(): Promise { }; } -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 }; - } -} +// Account counts come from the local login tracker, which records a per- +// instance HMAC of every successful login plus the timestamp. Total = unique +// identities seen in the last 90 days; active7d = identities with a login in +// the last 7 days. async function countExtensions(): Promise<{ extensions: number; themes: number }> { try { @@ -117,7 +97,7 @@ export async function buildPayload(): Promise { const instance_id = await getInstanceId(); const { version, build } = readPackage(); const features = await readFeatures(); - const accounts = await countAccounts(); + const accounts = await getLoginCounts(); const exts = await countExtensions(); const uptime_days = Math.min( 365,