import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; import { AccountingSessionDoc } from '../db/index.js'; /** * RADIUS accounting session */ export interface IAccountingSession { /** Unique session ID from RADIUS */ sessionId: string; /** Username (often MAC address for MAB) */ username: string; /** MAC address of the device */ macAddress?: string; /** NAS IP address (switch/AP) */ nasIpAddress: string; /** NAS port (physical or virtual) */ nasPort?: number; /** NAS port type */ nasPortType?: string; /** NAS identifier (name) */ nasIdentifier?: string; /** Assigned VLAN */ vlanId?: number; /** Assigned IP address (if any) */ framedIpAddress?: string; /** Called station ID (usually BSSID for wireless) */ calledStationId?: string; /** Calling station ID (usually client MAC) */ callingStationId?: string; /** Session start time */ startTime: number; /** Session end time (0 if active) */ endTime: number; /** Last update time (interim accounting) */ lastUpdateTime: number; /** Session status */ status: 'active' | 'stopped' | 'terminated'; /** Termination cause (if stopped) */ terminateCause?: string; /** Input octets (bytes received by NAS from client) */ inputOctets: number; /** Output octets (bytes sent by NAS to client) */ outputOctets: number; /** Input packets */ inputPackets: number; /** Output packets */ outputPackets: number; /** Session duration in seconds */ sessionTime: number; /** Service type */ serviceType?: string; } /** * Accounting summary for a time period */ export interface IAccountingSummary { /** Time period start */ periodStart: number; /** Time period end */ periodEnd: number; /** Total sessions */ totalSessions: number; /** Active sessions */ activeSessions: number; /** Total input bytes */ totalInputBytes: number; /** Total output bytes */ totalOutputBytes: number; /** Total session time (seconds) */ totalSessionTime: number; /** Average session duration (seconds) */ averageSessionDuration: number; /** Unique users/devices */ uniqueUsers: number; /** Sessions by VLAN */ sessionsByVlan: Record; /** Top users by traffic */ topUsersByTraffic: Array<{ username: string; totalBytes: number }>; } /** * Accounting manager configuration */ export interface IAccountingManagerConfig { /** Session retention period in days (default: 30) */ retentionDays?: number; /** Enable detailed session logging */ detailedLogging?: boolean; /** Maximum active sessions to track in memory */ maxActiveSessions?: number; /** Stale session timeout in hours — sessions with no update for this long are evicted (default: 24) */ staleSessionTimeoutHours?: number; } /** * Manages RADIUS accounting data including: * - Session tracking (start/stop/interim) * - Data usage tracking (bytes in/out) * - Session history and retention * - Billing reports and summaries */ export class AccountingManager { private activeSessions: Map = new Map(); private config: Required; private staleSessionSweepTimer?: ReturnType; // Counters for statistics private stats = { totalSessionsStarted: 0, totalSessionsStopped: 0, totalInputBytes: 0, totalOutputBytes: 0, interimUpdatesReceived: 0, }; constructor(config?: IAccountingManagerConfig) { this.config = { retentionDays: config?.retentionDays ?? 30, detailedLogging: config?.detailedLogging ?? false, maxActiveSessions: config?.maxActiveSessions ?? 10000, staleSessionTimeoutHours: config?.staleSessionTimeoutHours ?? 24, }; } /** * Initialize the accounting manager */ async initialize(): Promise { await this.loadActiveSessions(); // Start periodic sweep to evict stale sessions (every 15 minutes) this.staleSessionSweepTimer = setInterval(() => { this.sweepStaleSessions(); }, 15 * 60 * 1000); // Allow the process to exit even if the timer is pending if (this.staleSessionSweepTimer.unref) { this.staleSessionSweepTimer.unref(); } logger.log('info', `AccountingManager initialized with ${this.activeSessions.size} active sessions`); } /** * Stop the accounting manager and clean up timers */ stop(): void { if (this.staleSessionSweepTimer) { clearInterval(this.staleSessionSweepTimer); this.staleSessionSweepTimer = undefined; } } /** * Sweep stale active sessions that have not received any update * within the configured timeout. These are orphaned sessions where * the Stop packet was never received. */ private sweepStaleSessions(): void { const timeoutMs = this.config.staleSessionTimeoutHours * 60 * 60 * 1000; const cutoff = Date.now() - timeoutMs; let swept = 0; for (const [sessionId, session] of this.activeSessions) { if (session.lastUpdateTime < cutoff) { session.status = 'terminated'; session.terminateCause = 'StaleSessionTimeout'; session.endTime = Date.now(); session.sessionTime = Math.floor((session.endTime - session.startTime) / 1000); this.persistSession(session).catch(() => {}); this.activeSessions.delete(sessionId); swept++; } } if (swept > 0) { logger.log('info', `Swept ${swept} stale RADIUS sessions (no update for ${this.config.staleSessionTimeoutHours}h)`); } } /** * Handle accounting start request */ async handleAccountingStart(data: { sessionId: string; username: string; macAddress?: string; nasIpAddress: string; nasPort?: number; nasPortType?: string; nasIdentifier?: string; vlanId?: number; framedIpAddress?: string; calledStationId?: string; callingStationId?: string; serviceType?: string; }): Promise { const now = Date.now(); const session: IAccountingSession = { sessionId: data.sessionId, username: data.username, macAddress: data.macAddress, nasIpAddress: data.nasIpAddress, nasPort: data.nasPort, nasPortType: data.nasPortType, nasIdentifier: data.nasIdentifier, vlanId: data.vlanId, framedIpAddress: data.framedIpAddress, calledStationId: data.calledStationId, callingStationId: data.callingStationId, serviceType: data.serviceType, startTime: now, endTime: 0, lastUpdateTime: now, status: 'active', inputOctets: 0, outputOctets: 0, inputPackets: 0, outputPackets: 0, sessionTime: 0, }; // Check if we're at capacity if (this.activeSessions.size >= this.config.maxActiveSessions) { // Remove oldest session const oldest = this.findOldestSession(); if (oldest) { await this.evictSession(oldest); } } this.activeSessions.set(data.sessionId, session); this.stats.totalSessionsStarted++; if (this.config.detailedLogging) { logger.log('info', `Accounting Start: session=${data.sessionId}, user=${data.username}, NAS=${data.nasIpAddress}`); } // Persist session await this.persistSession(session); } /** * Handle accounting interim update request */ async handleAccountingUpdate(data: { sessionId: string; inputOctets?: number; outputOctets?: number; inputPackets?: number; outputPackets?: number; sessionTime?: number; }): Promise { const session = this.activeSessions.get(data.sessionId); if (!session) { logger.log('warn', `Interim update for unknown session: ${data.sessionId}`); return; } // Update session metrics if (data.inputOctets !== undefined) { session.inputOctets = data.inputOctets; } if (data.outputOctets !== undefined) { session.outputOctets = data.outputOctets; } if (data.inputPackets !== undefined) { session.inputPackets = data.inputPackets; } if (data.outputPackets !== undefined) { session.outputPackets = data.outputPackets; } if (data.sessionTime !== undefined) { session.sessionTime = data.sessionTime; } session.lastUpdateTime = Date.now(); this.stats.interimUpdatesReceived++; if (this.config.detailedLogging) { logger.log('debug', `Accounting Interim: session=${data.sessionId}, in=${data.inputOctets}, out=${data.outputOctets}`); } // Update persisted session await this.persistSession(session); } /** * Handle accounting stop request */ async handleAccountingStop(data: { sessionId: string; terminateCause?: string; inputOctets?: number; outputOctets?: number; inputPackets?: number; outputPackets?: number; sessionTime?: number; }): Promise { const session = this.activeSessions.get(data.sessionId); if (!session) { logger.log('warn', `Stop for unknown session: ${data.sessionId}`); return; } // Update final metrics if (data.inputOctets !== undefined) { session.inputOctets = data.inputOctets; } if (data.outputOctets !== undefined) { session.outputOctets = data.outputOctets; } if (data.inputPackets !== undefined) { session.inputPackets = data.inputPackets; } if (data.outputPackets !== undefined) { session.outputPackets = data.outputPackets; } if (data.sessionTime !== undefined) { session.sessionTime = data.sessionTime; } session.endTime = Date.now(); session.lastUpdateTime = session.endTime; session.status = 'stopped'; session.terminateCause = data.terminateCause; // Update global stats this.stats.totalSessionsStopped++; this.stats.totalInputBytes += session.inputOctets; this.stats.totalOutputBytes += session.outputOctets; if (this.config.detailedLogging) { logger.log('info', `Accounting Stop: session=${data.sessionId}, duration=${session.sessionTime}s, in=${session.inputOctets}, out=${session.outputOctets}`); } // Update status in the database (single collection, no active->archive move needed) await this.persistSession(session); // Remove from active sessions this.activeSessions.delete(data.sessionId); } /** * Get an active session by ID */ getSession(sessionId: string): IAccountingSession | undefined { return this.activeSessions.get(sessionId); } /** * Get all active sessions */ getActiveSessions(): IAccountingSession[] { return Array.from(this.activeSessions.values()); } /** * Get active sessions by username */ getSessionsByUsername(username: string): IAccountingSession[] { return Array.from(this.activeSessions.values()).filter(s => s.username === username); } /** * Get active sessions by NAS IP */ getSessionsByNas(nasIpAddress: string): IAccountingSession[] { return Array.from(this.activeSessions.values()).filter(s => s.nasIpAddress === nasIpAddress); } /** * Get active sessions by VLAN */ getSessionsByVlan(vlanId: number): IAccountingSession[] { return Array.from(this.activeSessions.values()).filter(s => s.vlanId === vlanId); } /** * Get accounting summary for a time period */ async getSummary(startTime: number, endTime: number): Promise { // Get archived sessions for the time period const archivedSessions = await this.getArchivedSessions(startTime, endTime); // Combine with active sessions that started within the period const activeSessions = Array.from(this.activeSessions.values()).filter( s => s.startTime >= startTime && s.startTime <= endTime ); const allSessions = [...archivedSessions, ...activeSessions]; // Calculate summary let totalInputBytes = 0; let totalOutputBytes = 0; let totalSessionTime = 0; const uniqueUsers = new Set(); const sessionsByVlan: Record = {}; const userTraffic: Record = {}; for (const session of allSessions) { totalInputBytes += session.inputOctets; totalOutputBytes += session.outputOctets; totalSessionTime += session.sessionTime; uniqueUsers.add(session.username); if (session.vlanId !== undefined) { sessionsByVlan[session.vlanId] = (sessionsByVlan[session.vlanId] || 0) + 1; } const userBytes = session.inputOctets + session.outputOctets; userTraffic[session.username] = (userTraffic[session.username] || 0) + userBytes; } // Top users by traffic const topUsersByTraffic = Object.entries(userTraffic) .sort((a, b) => b[1] - a[1]) .slice(0, 10) .map(([username, totalBytes]) => ({ username, totalBytes })); return { periodStart: startTime, periodEnd: endTime, totalSessions: allSessions.length, activeSessions: activeSessions.length, totalInputBytes, totalOutputBytes, totalSessionTime, averageSessionDuration: allSessions.length > 0 ? totalSessionTime / allSessions.length : 0, uniqueUsers: uniqueUsers.size, sessionsByVlan, topUsersByTraffic, }; } /** * Get statistics */ getStats(): { activeSessions: number; totalSessionsStarted: number; totalSessionsStopped: number; totalInputBytes: number; totalOutputBytes: number; interimUpdatesReceived: number; } { return { activeSessions: this.activeSessions.size, ...this.stats, }; } /** * Disconnect a session (admin action) */ async disconnectSession(sessionId: string, reason: string = 'AdminReset'): Promise { const session = this.activeSessions.get(sessionId); if (!session) { return false; } await this.handleAccountingStop({ sessionId, terminateCause: reason, sessionTime: Math.floor((Date.now() - session.startTime) / 1000), }); return true; } /** * Clean up old archived sessions based on retention policy */ async cleanupOldSessions(): Promise { const cutoffTime = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1000; let deletedCount = 0; try { const oldDocs = await AccountingSessionDoc.findStoppedBefore(cutoffTime); for (const doc of oldDocs) { try { await doc.delete(); deletedCount++; } catch (error) { // Ignore individual errors } } if (deletedCount > 0) { logger.log('info', `Cleaned up ${deletedCount} old accounting sessions`); } } catch (error: unknown) { logger.log('error', `Failed to cleanup old sessions: ${(error as Error).message}`); } return deletedCount; } /** * Find the oldest active session */ private findOldestSession(): string | null { let oldestTime = Infinity; let oldestSessionId: string | null = null; for (const [sessionId, session] of this.activeSessions) { if (session.lastUpdateTime < oldestTime) { oldestTime = session.lastUpdateTime; oldestSessionId = sessionId; } } return oldestSessionId; } /** * Evict a session from memory */ private async evictSession(sessionId: string): Promise { const session = this.activeSessions.get(sessionId); if (session) { session.status = 'terminated'; session.terminateCause = 'SessionEvicted'; session.endTime = Date.now(); await this.persistSession(session); this.activeSessions.delete(sessionId); logger.log('warn', `Evicted session ${sessionId} due to capacity limit`); } } /** * Load active sessions from database */ private async loadActiveSessions(): Promise { try { const docs = await AccountingSessionDoc.findActive(); for (const doc of docs) { const session: IAccountingSession = { sessionId: doc.sessionId, username: doc.username, macAddress: doc.macAddress, nasIpAddress: doc.nasIpAddress, nasPort: doc.nasPort, nasPortType: doc.nasPortType, nasIdentifier: doc.nasIdentifier, vlanId: doc.vlanId, framedIpAddress: doc.framedIpAddress, calledStationId: doc.calledStationId, callingStationId: doc.callingStationId, startTime: doc.startTime, endTime: doc.endTime, lastUpdateTime: doc.lastUpdateTime, status: doc.status, terminateCause: doc.terminateCause, inputOctets: doc.inputOctets, outputOctets: doc.outputOctets, inputPackets: doc.inputPackets, outputPackets: doc.outputPackets, sessionTime: doc.sessionTime, serviceType: doc.serviceType, }; this.activeSessions.set(session.sessionId, session); } } catch (error: unknown) { logger.log('warn', `Failed to load active sessions: ${(error as Error).message}`); } } /** * Persist a session to the database (create or update) */ private async persistSession(session: IAccountingSession): Promise { try { let doc = await AccountingSessionDoc.findBySessionId(session.sessionId); if (!doc) { doc = new AccountingSessionDoc(); } Object.assign(doc, session); await doc.save(); } catch (error: unknown) { logger.log('error', `Failed to persist session ${session.sessionId}: ${(error as Error).message}`); } } /** * Get archived (stopped/terminated) sessions for a time period */ private async getArchivedSessions(startTime: number, endTime: number): Promise { const sessions: IAccountingSession[] = []; try { const docs = await AccountingSessionDoc.getInstances({ status: { $in: ['stopped', 'terminated'] } as any, endTime: { $gt: 0, $gte: startTime } as any, startTime: { $lte: endTime } as any, }); for (const doc of docs) { sessions.push({ sessionId: doc.sessionId, username: doc.username, macAddress: doc.macAddress, nasIpAddress: doc.nasIpAddress, nasPort: doc.nasPort, nasPortType: doc.nasPortType, nasIdentifier: doc.nasIdentifier, vlanId: doc.vlanId, framedIpAddress: doc.framedIpAddress, calledStationId: doc.calledStationId, callingStationId: doc.callingStationId, startTime: doc.startTime, endTime: doc.endTime, lastUpdateTime: doc.lastUpdateTime, status: doc.status, terminateCause: doc.terminateCause, inputOctets: doc.inputOctets, outputOctets: doc.outputOctets, inputPackets: doc.inputPackets, outputPackets: doc.outputPackets, sessionTime: doc.sessionTime, serviceType: doc.serviceType, }); } } catch (error: unknown) { logger.log('warn', `Failed to get archived sessions: ${(error as Error).message}`); } return sessions; } }