feat: track unique logins
This commit is contained in:
+19
-1
@@ -31,6 +31,7 @@ export default function AdminDashboardPage() {
|
|||||||
const [pluginCount, setPluginCount] = useState(0);
|
const [pluginCount, setPluginCount] = useState(0);
|
||||||
const [themeCount, setThemeCount] = useState(0);
|
const [themeCount, setThemeCount] = useState(0);
|
||||||
const [policyRuleCount, setPolicyRuleCount] = 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');
|
const [jmapHealth, setJmapHealth] = useState<'unknown' | 'ok' | 'error'>('unknown');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,7 +39,7 @@ export default function AdminDashboardPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function fetchDashboardData() {
|
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/auth'),
|
||||||
apiFetch('/api/admin/audit?limit=10'),
|
apiFetch('/api/admin/audit?limit=10'),
|
||||||
apiFetch('/api/config'),
|
apiFetch('/api/config'),
|
||||||
@@ -46,6 +47,7 @@ export default function AdminDashboardPage() {
|
|||||||
apiFetch('/api/admin/plugins').catch(() => null),
|
apiFetch('/api/admin/plugins').catch(() => null),
|
||||||
apiFetch('/api/admin/themes').catch(() => null),
|
apiFetch('/api/admin/themes').catch(() => null),
|
||||||
apiFetch('/api/admin/policy').catch(() => null),
|
apiFetch('/api/admin/policy').catch(() => null),
|
||||||
|
apiFetch('/api/admin/telemetry').catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (statusRes.ok) setStatus(await statusRes.json());
|
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;
|
const disabledGates = policy.features ? Object.values(policy.features).filter((v: unknown) => !v).length : 0;
|
||||||
setPolicyRuleCount(restrictionCount + disabledGates);
|
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) {
|
if (configData?.jmapServerUrl) {
|
||||||
try {
|
try {
|
||||||
@@ -165,6 +173,16 @@ export default function AdminDashboardPage() {
|
|||||||
</SettingItem>
|
</SettingItem>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Accounts */}
|
||||||
|
<SettingsSection title="Accounts" description="Unique logins recorded over the last 90 days">
|
||||||
|
<SettingItem label="Total accounts" description="Distinct identities seen in the retention window">
|
||||||
|
<span className="text-sm text-foreground">{accountCounts?.total ?? '-'}</span>
|
||||||
|
</SettingItem>
|
||||||
|
<SettingItem label="Active in last 7 days" description="Identities with a login in the past week">
|
||||||
|
<span className="text-sm text-foreground">{accountCounts?.active7d ?? '-'}</span>
|
||||||
|
</SettingItem>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Extensions */}
|
{/* Extensions */}
|
||||||
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
|
<SettingsSection title="Extensions" description="Installed plugins, themes, and policy rules">
|
||||||
<SettingItem label="Plugins">
|
<SettingItem label="Plugins">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface TelemetryStatus {
|
|||||||
lastSentAt: string | null;
|
lastSentAt: string | null;
|
||||||
nextScheduledAt: string | null;
|
nextScheduledAt: string | null;
|
||||||
payloadPreview: Record<string, unknown>;
|
payloadPreview: Record<string, unknown>;
|
||||||
|
accountCounts: { total: number; active7d: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
function timeAgo(iso: string | null): string {
|
function timeAgo(iso: string | null): string {
|
||||||
@@ -173,6 +174,21 @@ export default function AdminTelemetryPage() {
|
|||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border p-4 space-y-2">
|
||||||
|
<div className="font-medium">Account activity</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<dl className="grid grid-cols-2 gap-2 text-sm pt-1">
|
||||||
|
<dt className="text-muted-foreground">Total (90d)</dt>
|
||||||
|
<dd className="font-mono">{status.accountCounts?.total ?? 0}</dd>
|
||||||
|
<dt className="text-muted-foreground">Active (7d)</dt>
|
||||||
|
<dd className="font-mono">{status.accountCounts?.active7d ?? 0}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="rounded-lg border p-4 space-y-3">
|
<section className="rounded-lg border p-4 space-y-3">
|
||||||
<div className="font-medium">Endpoint</div>
|
<div className="font-medium">Endpoint</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
sendOnce,
|
sendOnce,
|
||||||
reschedule,
|
reschedule,
|
||||||
DEFAULT_ENDPOINT,
|
DEFAULT_ENDPOINT,
|
||||||
|
getLoginCounts,
|
||||||
} from '@/lib/telemetry';
|
} from '@/lib/telemetry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,7 +24,10 @@ export async function GET() {
|
|||||||
if ('error' in auth) return auth.error;
|
if ('error' in auth) return auth.error;
|
||||||
|
|
||||||
const { consent, source, state } = await effectiveConsent();
|
const { consent, source, state } = await effectiveConsent();
|
||||||
const payload = await buildPayload();
|
const [payload, accountCounts] = await Promise.all([
|
||||||
|
buildPayload(),
|
||||||
|
getLoginCounts(),
|
||||||
|
]);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
@@ -35,6 +39,7 @@ export async function GET() {
|
|||||||
nextScheduledAt: state.nextScheduledAt,
|
nextScheduledAt: state.nextScheduledAt,
|
||||||
defaultEndpoint: DEFAULT_ENDPOINT,
|
defaultEndpoint: DEFAULT_ENDPOINT,
|
||||||
payloadPreview: payload,
|
payloadPreview: payload,
|
||||||
|
accountCounts,
|
||||||
},
|
},
|
||||||
{ headers: { 'Cache-Control': 'no-store' } },
|
{ headers: { 'Cache-Control': 'no-store' } },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
setStalwartAuthContextInStore,
|
setStalwartAuthContextInStore,
|
||||||
} from '@/lib/stalwart/auth-context';
|
} from '@/lib/stalwart/auth-context';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
...getCookieOptions(),
|
...getCookieOptions(),
|
||||||
@@ -50,6 +51,8 @@ export async function POST(request: NextRequest) {
|
|||||||
authHeader,
|
authHeader,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void recordLogin(username, normalizedServerUrl);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof JmapAuthVerificationError) {
|
if (error instanceof JmapAuthVerificationError) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||||
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||||
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
|
||||||
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
||||||
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
|
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
|
||||||
@@ -32,6 +33,8 @@ export async function POST(request: NextRequest) {
|
|||||||
authHeader,
|
authHeader,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void recordLogin(username, normalizedServerUrl);
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof JmapAuthVerificationError) {
|
if (error instanceof JmapAuthVerificationError) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
|||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
* 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 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) {
|
} catch (error) {
|
||||||
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
@@ -126,6 +127,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
async function attemptAllStrategies(
|
async function attemptAllStrategies(
|
||||||
tokenEndpoint: string,
|
tokenEndpoint: string,
|
||||||
|
serverUrl: string,
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
slot: number,
|
slot: number,
|
||||||
@@ -144,6 +146,7 @@ async function attemptAllStrategies(
|
|||||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
||||||
|
void recordLogin(username, serverUrl);
|
||||||
return await storeAndRespond(result.tokens, slot);
|
return await storeAndRespond(result.tokens, slot);
|
||||||
}
|
}
|
||||||
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
||||||
@@ -155,6 +158,7 @@ async function attemptAllStrategies(
|
|||||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
||||||
|
void recordLogin(username, serverUrl);
|
||||||
return await storeAndRespond(result.tokens, slot);
|
return await storeAndRespond(result.tokens, slot);
|
||||||
}
|
}
|
||||||
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
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 });
|
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
||||||
|
void recordLogin(username, serverUrl);
|
||||||
return await storeAndRespond(result.tokens, slot);
|
return await storeAndRespond(result.tokens, slot);
|
||||||
}
|
}
|
||||||
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
||||||
@@ -177,6 +182,7 @@ async function attemptAllStrategies(
|
|||||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
||||||
|
void recordLogin(username, serverUrl);
|
||||||
return await storeAndRespond(result.tokens, slot);
|
return await storeAndRespond(result.tokens, slot);
|
||||||
}
|
}
|
||||||
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export { buildPayload, markProcessStart } from './payload';
|
|||||||
export {
|
export {
|
||||||
loadState, saveState, getInstanceId, effectiveConsent,
|
loadState, saveState, getInstanceId, effectiveConsent,
|
||||||
} from './state';
|
} from './state';
|
||||||
|
export { recordLogin, getLoginCounts } from './login-tracker';
|
||||||
export type {
|
export type {
|
||||||
TelemetryPayload, TelemetryStateFile, ConsentState,
|
TelemetryPayload, TelemetryStateFile, ConsentState,
|
||||||
Platform, OsFamily, CountBucket, TelemetryFeatures,
|
Platform, OsFamily, CountBucket, TelemetryFeatures,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { logger } from '@/lib/logger';
|
|
||||||
import { getInstanceId } from './state';
|
import { getInstanceId } from './state';
|
||||||
|
import { getLoginCounts } from './login-tracker';
|
||||||
import type {
|
import type {
|
||||||
TelemetryPayload,
|
TelemetryPayload,
|
||||||
TelemetryFeatures,
|
TelemetryFeatures,
|
||||||
@@ -75,30 +75,10 @@ async function readFeatures(): Promise<TelemetryFeatures> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function countAccounts(): Promise<{ total: number; active7d: number }> {
|
// Account counts come from the local login tracker, which records a per-
|
||||||
// Best-effort. If Stalwart's admin endpoint isn't reachable from here we
|
// instance HMAC of every successful login plus the timestamp. Total = unique
|
||||||
// return 0 / 0 - the heartbeat still fires.
|
// identities seen in the last 90 days; active7d = identities with a login in
|
||||||
try {
|
// the last 7 days.
|
||||||
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 }> {
|
async function countExtensions(): Promise<{ extensions: number; themes: number }> {
|
||||||
try {
|
try {
|
||||||
@@ -117,7 +97,7 @@ export async function buildPayload(): Promise<TelemetryPayload> {
|
|||||||
const instance_id = await getInstanceId();
|
const instance_id = await getInstanceId();
|
||||||
const { version, build } = readPackage();
|
const { version, build } = readPackage();
|
||||||
const features = await readFeatures();
|
const features = await readFeatures();
|
||||||
const accounts = await countAccounts();
|
const accounts = await getLoginCounts();
|
||||||
const exts = await countExtensions();
|
const exts = await countExtensions();
|
||||||
const uptime_days = Math.min(
|
const uptime_days = Math.min(
|
||||||
365,
|
365,
|
||||||
|
|||||||
Reference in New Issue
Block a user