import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; import type { StorageManager } from '../storage/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 { /** Storage key prefix */ storagePrefix?: string; /** Session retention period in days (default: 30) */ retentionDays?: number; /** Enable detailed session logging */ detailedLogging?: boolean; /** Maximum active sessions to track in memory */ maxActiveSessions?: 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 storageManager?: StorageManager; // Counters for statistics private stats = { totalSessionsStarted: 0, totalSessionsStopped: 0, totalInputBytes: 0, totalOutputBytes: 0, interimUpdatesReceived: 0, }; constructor(config?: IAccountingManagerConfig, storageManager?: StorageManager) { this.config = { storagePrefix: config?.storagePrefix ?? '/radius/accounting', retentionDays: config?.retentionDays ?? 30, detailedLogging: config?.detailedLogging ?? false, maxActiveSessions: config?.maxActiveSessions ?? 10000, }; this.storageManager = storageManager; } /** * Initialize the accounting manager */ async initialize(): Promise { if (this.storageManager) { await this.loadActiveSessions(); } logger.log('info', `AccountingManager initialized with ${this.activeSessions.size} active sessions`); } /** * 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 if (this.storageManager) { 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 if (this.storageManager) { 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}`); } // Archive the session if (this.storageManager) { await this.archiveSession(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 { if (!this.storageManager) { return 0; } const cutoffTime = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1000; let deletedCount = 0; try { const keys = await this.storageManager.list(`${this.config.storagePrefix}/archive/`); for (const key of keys) { try { const session = await this.storageManager.getJSON(key); if (session && session.endTime > 0 && session.endTime < cutoffTime) { await this.storageManager.delete(key); deletedCount++; } } catch (error) { // Ignore individual errors } } if (deletedCount > 0) { logger.log('info', `Cleaned up ${deletedCount} old accounting sessions`); } } catch (error) { logger.log('error', `Failed to cleanup old sessions: ${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(); if (this.storageManager) { await this.archiveSession(session); } this.activeSessions.delete(sessionId); logger.log('warn', `Evicted session ${sessionId} due to capacity limit`); } } /** * Load active sessions from storage */ private async loadActiveSessions(): Promise { if (!this.storageManager) { return; } try { const keys = await this.storageManager.list(`${this.config.storagePrefix}/active/`); for (const key of keys) { try { const session = await this.storageManager.getJSON(key); if (session && session.status === 'active') { this.activeSessions.set(session.sessionId, session); } } catch (error) { // Ignore individual errors } } } catch (error) { logger.log('warn', `Failed to load active sessions: ${error.message}`); } } /** * Persist a session to storage */ private async persistSession(session: IAccountingSession): Promise { if (!this.storageManager) { return; } const key = `${this.config.storagePrefix}/active/${session.sessionId}.json`; try { await this.storageManager.setJSON(key, session); } catch (error) { logger.log('error', `Failed to persist session ${session.sessionId}: ${error.message}`); } } /** * Archive a completed session */ private async archiveSession(session: IAccountingSession): Promise { if (!this.storageManager) { return; } try { // Remove from active const activeKey = `${this.config.storagePrefix}/active/${session.sessionId}.json`; await this.storageManager.delete(activeKey); // Add to archive with date-based path const date = new Date(session.endTime); const archiveKey = `${this.config.storagePrefix}/archive/${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}/${session.sessionId}.json`; await this.storageManager.setJSON(archiveKey, session); } catch (error) { logger.log('error', `Failed to archive session ${session.sessionId}: ${error.message}`); } } /** * Get archived sessions for a time period */ private async getArchivedSessions(startTime: number, endTime: number): Promise { if (!this.storageManager) { return []; } const sessions: IAccountingSession[] = []; try { const keys = await this.storageManager.list(`${this.config.storagePrefix}/archive/`); for (const key of keys) { try { const session = await this.storageManager.getJSON(key); if ( session && session.endTime > 0 && session.startTime <= endTime && session.endTime >= startTime ) { sessions.push(session); } } catch (error) { // Ignore individual errors } } } catch (error) { logger.log('warn', `Failed to get archived sessions: ${error.message}`); } return sessions; } }