feat: track unique logins

This commit is contained in:
Linus Rath
2026-04-28 08:19:46 +02:00
parent 2c419cc4fe
commit dafc8ace3c
9 changed files with 186 additions and 29 deletions
+1
View File
@@ -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,
+125
View File
@@ -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<void> {
const dir = getDir();
if (!existsSync(dir)) await mkdir(dir, { recursive: true });
}
async function loadFile(): Promise<LoginsFile> {
if (cache) return cache;
try {
const raw = await readFile(loginsPath(), 'utf8');
const parsed = JSON.parse(raw) as Partial<LoginsFile>;
cache = Array.isArray(parsed?.records) ? { records: parsed.records as LoginRecord[] } : { records: [] };
} catch {
cache = { records: [] };
}
return cache;
}
async function saveFile(file: LoginsFile): Promise<void> {
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<string> {
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<void> {
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 };
}
}
+6 -26
View File
@@ -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<TelemetryFeatures> {
};
}
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<TelemetryPayload> {
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,