195 lines
5.6 KiB
TypeScript
195 lines
5.6 KiB
TypeScript
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<string, number>;
|
|
lastSeenByName: Map<string, string | null>;
|
|
recentUsers: Set<string>;
|
|
}
|
|
|
|
interface ParsedLogRecord {
|
|
timestamp: Date;
|
|
username: string;
|
|
bytes: number;
|
|
}
|
|
|
|
export async function readObservedTraffic(
|
|
runtimePaths: RuntimePaths,
|
|
users: ProxyUserRecord[],
|
|
now = new Date(),
|
|
): Promise<ObservedTraffic> {
|
|
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<string, string | null>(users.map((user) => [user.username, null]));
|
|
const recentUsers = new Set<string>();
|
|
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<ParsedLogRecord[]> {
|
|
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<ParsedLogRecord[]> {
|
|
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}`;
|
|
}
|