feat(radius): add RADIUS server with MAC authentication (MAB), VLAN assignment, accounting and OpsServer API handlers
This commit is contained in:
607
ts/radius/classes.accounting.manager.ts
Normal file
607
ts/radius/classes.accounting.manager.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user