import fs from 'node:fs/promises'; import path from 'node:path'; import type { DailyTrafficBucket, ProxyUserRecord } from '../../src/shared/contracts'; import type { RuntimePaths } from './config'; const LOG_FILE_PREFIX = '3proxy.log'; const RECENT_WINDOW_MS = 15 * 60 * 1000; const CONNECTION_WINDOW_MS = 5 * 60 * 1000; const HISTORY_DAYS = 5; export interface ObservedTraffic { totalBytes: number; liveConnections: number; activeUsers: number; daily: DailyTrafficBucket[]; userBytesByName: Map; lastSeenByName: Map; recentUsers: Set; } interface ParsedLogRecord { timestamp: Date; username: string; bytes: number; } export async function readObservedTraffic( runtimePaths: RuntimePaths, users: ProxyUserRecord[], now = new Date(), ): Promise { const records = await readParsedRecords(runtimePaths.logPath); const currentDayKey = toDayKey(now); const historyStart = startOfDay(now); const history = buildHistoryDays(historyStart, HISTORY_DAYS); const dailyTotals = new Map(history.map((entry) => [entry.key, 0])); const userBytesByName = new Map(users.map((user) => [user.username, 0])); const lastSeenByName = new Map(users.map((user) => [user.username, null])); const recentUsers = new Set(); let liveConnections = 0; records.forEach((record) => { const dayKey = toDayKey(record.timestamp); if (dailyTotals.has(dayKey)) { dailyTotals.set(dayKey, (dailyTotals.get(dayKey) ?? 0) + record.bytes); } if (dayKey === currentDayKey && userBytesByName.has(record.username)) { userBytesByName.set(record.username, (userBytesByName.get(record.username) ?? 0) + record.bytes); } if (lastSeenByName.has(record.username)) { const currentLastSeen = lastSeenByName.get(record.username); if (!currentLastSeen || record.timestamp.toISOString() > currentLastSeen) { lastSeenByName.set(record.username, record.timestamp.toISOString()); } } const ageMs = now.getTime() - record.timestamp.getTime(); if (ageMs >= 0 && ageMs <= RECENT_WINDOW_MS) { recentUsers.add(record.username); } if (ageMs >= 0 && ageMs <= CONNECTION_WINDOW_MS) { liveConnections += 1; } }); const activeUsers = Array.from(userBytesByName.values()).filter((value) => value > 0).length; const totalBytes = Array.from(userBytesByName.values()).reduce((sum, value) => sum + value, 0); const daily = history.map((entry) => ({ day: entry.label, bytes: dailyTotals.get(entry.key) ?? 0, share: 0, })); const peak = Math.max(...daily.map((entry) => entry.bytes), 0); daily.forEach((entry) => { entry.share = peak > 0 ? entry.bytes / peak : 0; }); return { totalBytes, liveConnections, activeUsers, daily, userBytesByName, lastSeenByName, recentUsers, }; } async function readParsedRecords(logPath: string): Promise { const logDir = path.dirname(logPath); try { const files = await fs.readdir(logDir); const logFiles = files .filter((file) => file === LOG_FILE_PREFIX || file.startsWith(`${LOG_FILE_PREFIX}.`)) .sort() .slice(-HISTORY_DAYS - 2); const parsed = await Promise.all(logFiles.map(async (file) => parseLogFile(path.join(logDir, file)))); return parsed.flat(); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return []; } throw error; } } async function parseLogFile(filePath: string): Promise { try { const content = await fs.readFile(filePath, 'utf8'); return content .split(/\r?\n/) .map(parseLogLine) .filter((record): record is ParsedLogRecord => record !== null); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return []; } throw error; } } function parseLogLine(line: string): ParsedLogRecord | null { const trimmed = line.trim(); if (!trimmed || trimmed.includes('Accepting connections') || trimmed.includes('Exiting thread')) { return null; } const match = trimmed.match( /^(\d{12}\.\d{3})\s+\d+\s+\d+\s+(\S+)\s+\S+\s+\S+\s+(\d+)\s+(\d+)\s+\d+\s+/, ); if (!match) { return null; } const [, rawTimestamp, username, bytesIn, bytesOut] = match; if (username === '-') { return null; } const timestamp = parse3proxyTimestamp(rawTimestamp); if (!timestamp) { return null; } return { timestamp, username, bytes: Number(bytesIn) + Number(bytesOut), }; } function parse3proxyTimestamp(value: string): Date | null { const match = value.match(/^(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.(\d{3})$/); if (!match) { return null; } const [, yy, mm, dd, hh, min, ss, ms] = match; const year = 2000 + Number(yy); const date = new Date(year, Number(mm) - 1, Number(dd), Number(hh), Number(min), Number(ss), Number(ms)); return Number.isNaN(date.getTime()) ? null : date; } function buildHistoryDays(today: Date, count: number) { return Array.from({ length: count }, (_value, index) => { const date = new Date(today); date.setDate(today.getDate() - (count - index - 1)); return { key: toDayKey(date), label: date.toLocaleDateString('en-US', { weekday: 'short' }), }; }); } function startOfDay(date: Date): Date { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } function toDayKey(date: Date): string { const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${date.getFullYear()}-${month}-${day}`; }