Files
dcrouter/ts/radius/classes.accounting.manager.ts

608 lines
17 KiB
TypeScript

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<number, number>;
/** 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<string, IAccountingSession> = new Map();
private config: Required<IAccountingManagerConfig>;
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<void> {
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<void> {
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<void> {
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<void> {
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<IAccountingSummary> {
// 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<string>();
const sessionsByVlan: Record<number, number> = {};
const userTraffic: Record<string, number> = {};
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<boolean> {
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<number> {
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<IAccountingSession>(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<void> {
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<void> {
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<IAccountingSession>(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<void> {
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<void> {
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<IAccountingSession[]> {
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<IAccountingSession>(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;
}
}