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;
|
||||
}
|
||||
}
|
||||
532
ts/radius/classes.radius.server.ts
Normal file
532
ts/radius/classes.radius.server.ts
Normal file
@@ -0,0 +1,532 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { StorageManager } from '../storage/index.js';
|
||||
import { VlanManager, type IMacVlanMapping, type IVlanManagerConfig } from './classes.vlan.manager.js';
|
||||
import { AccountingManager, type IAccountingSession, type IAccountingManagerConfig } from './classes.accounting.manager.js';
|
||||
|
||||
/**
|
||||
* RADIUS client (NAS) configuration
|
||||
*/
|
||||
export interface IRadiusClient {
|
||||
/** Client name for identification */
|
||||
name: string;
|
||||
/** IP address or CIDR range */
|
||||
ipRange: string;
|
||||
/** Shared secret for this client */
|
||||
secret: string;
|
||||
/** Optional description */
|
||||
description?: string;
|
||||
/** Whether this client is enabled */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* RADIUS server configuration
|
||||
*/
|
||||
export interface IRadiusServerConfig {
|
||||
/** Authentication port (default: 1812) */
|
||||
authPort?: number;
|
||||
/** Accounting port (default: 1813) */
|
||||
acctPort?: number;
|
||||
/** Bind address (default: 0.0.0.0) */
|
||||
bindAddress?: string;
|
||||
/** NAS clients configuration */
|
||||
clients: IRadiusClient[];
|
||||
/** VLAN assignment configuration */
|
||||
vlanAssignment?: IVlanManagerConfig & {
|
||||
/** Static MAC to VLAN mappings */
|
||||
mappings?: Array<Omit<IMacVlanMapping, 'createdAt' | 'updatedAt'>>;
|
||||
};
|
||||
/** Accounting configuration */
|
||||
accounting?: IAccountingManagerConfig & {
|
||||
/** Whether accounting is enabled */
|
||||
enabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RADIUS authentication result
|
||||
*/
|
||||
export interface IRadiusAuthResult {
|
||||
/** Whether authentication was successful */
|
||||
success: boolean;
|
||||
/** Reject reason (if not successful) */
|
||||
rejectReason?: string;
|
||||
/** Reply message to send to client */
|
||||
replyMessage?: string;
|
||||
/** Session timeout in seconds */
|
||||
sessionTimeout?: number;
|
||||
/** Idle timeout in seconds */
|
||||
idleTimeout?: number;
|
||||
/** VLAN to assign */
|
||||
vlanId?: number;
|
||||
/** Framed IP address to assign */
|
||||
framedIpAddress?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication request data from RADIUS
|
||||
*/
|
||||
export interface IAuthRequestData {
|
||||
username: string;
|
||||
password?: string;
|
||||
nasIpAddress: string;
|
||||
nasPort?: number;
|
||||
nasPortType?: string;
|
||||
nasIdentifier?: string;
|
||||
calledStationId?: string;
|
||||
callingStationId?: string;
|
||||
serviceType?: string;
|
||||
framedMtu?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* RADIUS Server wrapper that provides:
|
||||
* - MAC Authentication Bypass (MAB) for network devices
|
||||
* - VLAN assignment based on MAC address
|
||||
* - Accounting for session tracking and billing
|
||||
* - Integration with SmartProxy routing
|
||||
*/
|
||||
export class RadiusServer {
|
||||
private radiusServer?: plugins.smartradius.RadiusServer;
|
||||
private vlanManager: VlanManager;
|
||||
private accountingManager: AccountingManager;
|
||||
private config: IRadiusServerConfig;
|
||||
private storageManager?: StorageManager;
|
||||
private clientSecrets: Map<string, string> = new Map();
|
||||
private running: boolean = false;
|
||||
|
||||
// Statistics
|
||||
private stats = {
|
||||
authRequests: 0,
|
||||
authAccepts: 0,
|
||||
authRejects: 0,
|
||||
accountingRequests: 0,
|
||||
startTime: 0,
|
||||
};
|
||||
|
||||
constructor(config: IRadiusServerConfig, storageManager?: StorageManager) {
|
||||
this.config = {
|
||||
authPort: config.authPort ?? 1812,
|
||||
acctPort: config.acctPort ?? 1813,
|
||||
bindAddress: config.bindAddress ?? '0.0.0.0',
|
||||
...config,
|
||||
};
|
||||
this.storageManager = storageManager;
|
||||
|
||||
// Initialize VLAN manager
|
||||
this.vlanManager = new VlanManager(config.vlanAssignment, storageManager);
|
||||
|
||||
// Initialize accounting manager
|
||||
this.accountingManager = new AccountingManager(config.accounting, storageManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the RADIUS server
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.running) {
|
||||
logger.log('warn', 'RADIUS server is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('info', `Starting RADIUS server on ${this.config.bindAddress}:${this.config.authPort} (auth) and ${this.config.acctPort} (acct)`);
|
||||
|
||||
// Initialize managers
|
||||
await this.vlanManager.initialize();
|
||||
await this.accountingManager.initialize();
|
||||
|
||||
// Import static VLAN mappings if provided
|
||||
if (this.config.vlanAssignment?.mappings) {
|
||||
await this.vlanManager.importMappings(this.config.vlanAssignment.mappings);
|
||||
}
|
||||
|
||||
// Build client secrets map
|
||||
this.buildClientSecretsMap();
|
||||
|
||||
// Create the RADIUS server
|
||||
this.radiusServer = new plugins.smartradius.RadiusServer({
|
||||
authPort: this.config.authPort,
|
||||
acctPort: this.config.acctPort,
|
||||
bindAddress: this.config.bindAddress,
|
||||
defaultSecret: this.getDefaultSecret(),
|
||||
authenticationHandler: this.handleAuthentication.bind(this),
|
||||
accountingHandler: this.handleAccounting.bind(this),
|
||||
});
|
||||
|
||||
// Configure per-client secrets
|
||||
for (const [ip, secret] of this.clientSecrets) {
|
||||
this.radiusServer.setClientSecret(ip, secret);
|
||||
}
|
||||
|
||||
// Start the server
|
||||
await this.radiusServer.start();
|
||||
|
||||
this.running = true;
|
||||
this.stats.startTime = Date.now();
|
||||
|
||||
logger.log('info', `RADIUS server started with ${this.config.clients.length} configured clients`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the RADIUS server
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (!this.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log('info', 'Stopping RADIUS server...');
|
||||
|
||||
if (this.radiusServer) {
|
||||
await this.radiusServer.stop();
|
||||
this.radiusServer = undefined;
|
||||
}
|
||||
|
||||
this.running = false;
|
||||
logger.log('info', 'RADIUS server stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authentication request
|
||||
*/
|
||||
private async handleAuthentication(request: any): Promise<any> {
|
||||
this.stats.authRequests++;
|
||||
|
||||
const authData: IAuthRequestData = {
|
||||
username: request.attributes?.UserName || '',
|
||||
password: request.attributes?.UserPassword,
|
||||
nasIpAddress: request.attributes?.NasIpAddress || request.source?.address || '',
|
||||
nasPort: request.attributes?.NasPort,
|
||||
nasPortType: request.attributes?.NasPortType,
|
||||
nasIdentifier: request.attributes?.NasIdentifier,
|
||||
calledStationId: request.attributes?.CalledStationId,
|
||||
callingStationId: request.attributes?.CallingStationId,
|
||||
serviceType: request.attributes?.ServiceType,
|
||||
};
|
||||
|
||||
logger.log('debug', `RADIUS Auth Request: user=${authData.username}, NAS=${authData.nasIpAddress}`);
|
||||
|
||||
// Perform MAC Authentication Bypass (MAB)
|
||||
// In MAB, the username is typically the MAC address
|
||||
const result = await this.performMabAuthentication(authData);
|
||||
|
||||
if (result.success) {
|
||||
this.stats.authAccepts++;
|
||||
logger.log('info', `RADIUS Auth Accept: user=${authData.username}, VLAN=${result.vlanId}`);
|
||||
|
||||
// Build response with VLAN attributes
|
||||
const response: any = {
|
||||
code: plugins.smartradius.ERadiusCode.AccessAccept,
|
||||
replyMessage: result.replyMessage,
|
||||
};
|
||||
|
||||
// Add VLAN attributes if assigned
|
||||
if (result.vlanId !== undefined) {
|
||||
response.tunnelType = 13; // VLAN
|
||||
response.tunnelMediumType = 6; // IEEE 802
|
||||
response.tunnelPrivateGroupId = String(result.vlanId);
|
||||
}
|
||||
|
||||
// Add session timeout if specified
|
||||
if (result.sessionTimeout) {
|
||||
response.sessionTimeout = result.sessionTimeout;
|
||||
}
|
||||
|
||||
// Add idle timeout if specified
|
||||
if (result.idleTimeout) {
|
||||
response.idleTimeout = result.idleTimeout;
|
||||
}
|
||||
|
||||
// Add framed IP if specified
|
||||
if (result.framedIpAddress) {
|
||||
response.framedIpAddress = result.framedIpAddress;
|
||||
}
|
||||
|
||||
return response;
|
||||
} else {
|
||||
this.stats.authRejects++;
|
||||
logger.log('warn', `RADIUS Auth Reject: user=${authData.username}, reason=${result.rejectReason}`);
|
||||
|
||||
return {
|
||||
code: plugins.smartradius.ERadiusCode.AccessReject,
|
||||
replyMessage: result.rejectReason || 'Access Denied',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle accounting request
|
||||
*/
|
||||
private async handleAccounting(request: any): Promise<any> {
|
||||
this.stats.accountingRequests++;
|
||||
|
||||
if (!this.config.accounting?.enabled) {
|
||||
// Still respond even if not tracking
|
||||
return { code: plugins.smartradius.ERadiusCode.AccountingResponse };
|
||||
}
|
||||
|
||||
const statusType = request.attributes?.AcctStatusType;
|
||||
const sessionId = request.attributes?.AcctSessionId || '';
|
||||
|
||||
const accountingData = {
|
||||
sessionId,
|
||||
username: request.attributes?.UserName || '',
|
||||
macAddress: request.attributes?.CallingStationId,
|
||||
nasIpAddress: request.attributes?.NasIpAddress || request.source?.address || '',
|
||||
nasPort: request.attributes?.NasPort,
|
||||
nasPortType: request.attributes?.NasPortType,
|
||||
nasIdentifier: request.attributes?.NasIdentifier,
|
||||
calledStationId: request.attributes?.CalledStationId,
|
||||
callingStationId: request.attributes?.CallingStationId,
|
||||
inputOctets: request.attributes?.AcctInputOctets,
|
||||
outputOctets: request.attributes?.AcctOutputOctets,
|
||||
inputPackets: request.attributes?.AcctInputPackets,
|
||||
outputPackets: request.attributes?.AcctOutputPackets,
|
||||
sessionTime: request.attributes?.AcctSessionTime,
|
||||
terminateCause: request.attributes?.AcctTerminateCause,
|
||||
serviceType: request.attributes?.ServiceType,
|
||||
};
|
||||
|
||||
try {
|
||||
switch (statusType) {
|
||||
case plugins.smartradius.EAcctStatusType.Start:
|
||||
logger.log('debug', `RADIUS Acct Start: session=${sessionId}, user=${accountingData.username}`);
|
||||
await this.accountingManager.handleAccountingStart(accountingData);
|
||||
break;
|
||||
|
||||
case plugins.smartradius.EAcctStatusType.Stop:
|
||||
logger.log('debug', `RADIUS Acct Stop: session=${sessionId}`);
|
||||
await this.accountingManager.handleAccountingStop(accountingData);
|
||||
break;
|
||||
|
||||
case plugins.smartradius.EAcctStatusType.InterimUpdate:
|
||||
logger.log('debug', `RADIUS Acct Interim: session=${sessionId}`);
|
||||
await this.accountingManager.handleAccountingUpdate(accountingData);
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.log('debug', `RADIUS Acct Unknown status type: ${statusType}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('error', `RADIUS accounting error: ${error.message}`);
|
||||
}
|
||||
|
||||
return { code: plugins.smartradius.ERadiusCode.AccountingResponse };
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform MAC Authentication Bypass
|
||||
*/
|
||||
private async performMabAuthentication(data: IAuthRequestData): Promise<IRadiusAuthResult> {
|
||||
// Extract MAC address from username or CallingStationId
|
||||
const macAddress = this.extractMacAddress(data);
|
||||
|
||||
if (!macAddress) {
|
||||
return {
|
||||
success: false,
|
||||
rejectReason: 'No MAC address found',
|
||||
};
|
||||
}
|
||||
|
||||
// Look up VLAN assignment
|
||||
const vlanResult = this.vlanManager.assignVlan(macAddress);
|
||||
|
||||
if (!vlanResult.assigned) {
|
||||
return {
|
||||
success: false,
|
||||
rejectReason: 'Unknown MAC address',
|
||||
};
|
||||
}
|
||||
|
||||
// Build successful result
|
||||
const result: IRadiusAuthResult = {
|
||||
success: true,
|
||||
vlanId: vlanResult.vlan,
|
||||
replyMessage: vlanResult.isDefault
|
||||
? `Assigned to default VLAN ${vlanResult.vlan}`
|
||||
: `Assigned to VLAN ${vlanResult.vlan}`,
|
||||
};
|
||||
|
||||
// Apply any additional settings from the matched rule
|
||||
if (vlanResult.matchedRule) {
|
||||
// Future: Add session timeout, idle timeout, etc. from rule
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract MAC address from authentication data
|
||||
*/
|
||||
private extractMacAddress(data: IAuthRequestData): string | null {
|
||||
// Try CallingStationId first (most common for MAB)
|
||||
if (data.callingStationId) {
|
||||
return this.normalizeMac(data.callingStationId);
|
||||
}
|
||||
|
||||
// Try username (often MAC address in MAB)
|
||||
if (data.username && this.looksLikeMac(data.username)) {
|
||||
return this.normalizeMac(data.username);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string looks like a MAC address
|
||||
*/
|
||||
private looksLikeMac(value: string): boolean {
|
||||
// Remove common separators and check length
|
||||
const cleaned = value.replace(/[-:. ]/g, '');
|
||||
return /^[0-9a-fA-F]{12}$/.test(cleaned);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize MAC address format
|
||||
*/
|
||||
private normalizeMac(mac: string): string {
|
||||
return this.vlanManager.normalizeMac(mac);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build client secrets map from configuration
|
||||
*/
|
||||
private buildClientSecretsMap(): void {
|
||||
this.clientSecrets.clear();
|
||||
|
||||
for (const client of this.config.clients) {
|
||||
if (!client.enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle CIDR ranges
|
||||
if (client.ipRange.includes('/')) {
|
||||
// For CIDR ranges, we'll use the network address as key
|
||||
// In practice, smartradius may handle this differently
|
||||
const [network] = client.ipRange.split('/');
|
||||
this.clientSecrets.set(network, client.secret);
|
||||
} else {
|
||||
this.clientSecrets.set(client.ipRange, client.secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default secret for unknown clients
|
||||
*/
|
||||
private getDefaultSecret(): string {
|
||||
// Use first enabled client's secret as default, or a random one
|
||||
for (const client of this.config.clients) {
|
||||
if (client.enabled) {
|
||||
return client.secret;
|
||||
}
|
||||
}
|
||||
return plugins.crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a RADIUS client
|
||||
*/
|
||||
async addClient(client: IRadiusClient): Promise<void> {
|
||||
// Check if client already exists
|
||||
const existingIndex = this.config.clients.findIndex(c => c.name === client.name);
|
||||
if (existingIndex >= 0) {
|
||||
this.config.clients[existingIndex] = client;
|
||||
} else {
|
||||
this.config.clients.push(client);
|
||||
}
|
||||
|
||||
// Update client secrets if running
|
||||
if (this.running && this.radiusServer && client.enabled) {
|
||||
if (client.ipRange.includes('/')) {
|
||||
const [network] = client.ipRange.split('/');
|
||||
this.radiusServer.setClientSecret(network, client.secret);
|
||||
this.clientSecrets.set(network, client.secret);
|
||||
} else {
|
||||
this.radiusServer.setClientSecret(client.ipRange, client.secret);
|
||||
this.clientSecrets.set(client.ipRange, client.secret);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('info', `RADIUS client ${client.enabled ? 'added' : 'disabled'}: ${client.name} (${client.ipRange})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a RADIUS client
|
||||
*/
|
||||
removeClient(name: string): boolean {
|
||||
const index = this.config.clients.findIndex(c => c.name === name);
|
||||
if (index >= 0) {
|
||||
const client = this.config.clients[index];
|
||||
this.config.clients.splice(index, 1);
|
||||
|
||||
// Remove from secrets map
|
||||
if (client.ipRange.includes('/')) {
|
||||
const [network] = client.ipRange.split('/');
|
||||
this.clientSecrets.delete(network);
|
||||
} else {
|
||||
this.clientSecrets.delete(client.ipRange);
|
||||
}
|
||||
|
||||
logger.log('info', `RADIUS client removed: ${name}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configured clients
|
||||
*/
|
||||
getClients(): IRadiusClient[] {
|
||||
return [...this.config.clients];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VLAN manager for direct access to VLAN operations
|
||||
*/
|
||||
getVlanManager(): VlanManager {
|
||||
return this.vlanManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accounting manager for direct access to accounting operations
|
||||
*/
|
||||
getAccountingManager(): AccountingManager {
|
||||
return this.accountingManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server statistics
|
||||
*/
|
||||
getStats(): {
|
||||
running: boolean;
|
||||
uptime: number;
|
||||
authRequests: number;
|
||||
authAccepts: number;
|
||||
authRejects: number;
|
||||
accountingRequests: number;
|
||||
activeSessions: number;
|
||||
vlanMappings: number;
|
||||
clients: number;
|
||||
} {
|
||||
return {
|
||||
running: this.running,
|
||||
uptime: this.running ? Date.now() - this.stats.startTime : 0,
|
||||
authRequests: this.stats.authRequests,
|
||||
authAccepts: this.stats.authAccepts,
|
||||
authRejects: this.stats.authRejects,
|
||||
accountingRequests: this.stats.accountingRequests,
|
||||
activeSessions: this.accountingManager.getStats().activeSessions,
|
||||
vlanMappings: this.vlanManager.getStats().totalMappings,
|
||||
clients: this.config.clients.filter(c => c.enabled).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if server is running
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.running;
|
||||
}
|
||||
}
|
||||
363
ts/radius/classes.vlan.manager.ts
Normal file
363
ts/radius/classes.vlan.manager.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type { StorageManager } from '../storage/index.js';
|
||||
|
||||
/**
|
||||
* MAC address to VLAN mapping
|
||||
*/
|
||||
export interface IMacVlanMapping {
|
||||
/** MAC address (full) or OUI pattern (e.g., "00:11:22" for vendor prefix) */
|
||||
mac: string;
|
||||
/** VLAN ID to assign */
|
||||
vlan: number;
|
||||
/** Optional description */
|
||||
description?: string;
|
||||
/** Whether this mapping is enabled */
|
||||
enabled: boolean;
|
||||
/** Creation timestamp */
|
||||
createdAt: number;
|
||||
/** Last update timestamp */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* VLAN assignment result
|
||||
*/
|
||||
export interface IVlanAssignmentResult {
|
||||
/** Whether a VLAN was successfully assigned */
|
||||
assigned: boolean;
|
||||
/** The assigned VLAN ID (or default if not matched) */
|
||||
vlan: number;
|
||||
/** The matching rule (if any) */
|
||||
matchedRule?: IMacVlanMapping;
|
||||
/** Whether default VLAN was used */
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* VlanManager configuration
|
||||
*/
|
||||
export interface IVlanManagerConfig {
|
||||
/** Default VLAN for unknown MACs */
|
||||
defaultVlan?: number;
|
||||
/** Whether to allow unknown MACs (assign default VLAN) or reject */
|
||||
allowUnknownMacs?: boolean;
|
||||
/** Storage key prefix for persistence */
|
||||
storagePrefix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages MAC address to VLAN mappings with support for:
|
||||
* - Exact MAC address matching
|
||||
* - OUI (vendor prefix) pattern matching
|
||||
* - Wildcard patterns
|
||||
* - Default VLAN for unknown devices
|
||||
*/
|
||||
export class VlanManager {
|
||||
private mappings: Map<string, IMacVlanMapping> = new Map();
|
||||
private config: Required<IVlanManagerConfig>;
|
||||
private storageManager?: StorageManager;
|
||||
|
||||
// Cache for normalized MAC lookups
|
||||
private normalizedMacCache: Map<string, string> = new Map();
|
||||
|
||||
constructor(config?: IVlanManagerConfig, storageManager?: StorageManager) {
|
||||
this.config = {
|
||||
defaultVlan: config?.defaultVlan ?? 1,
|
||||
allowUnknownMacs: config?.allowUnknownMacs ?? true,
|
||||
storagePrefix: config?.storagePrefix ?? '/radius/vlan-mappings',
|
||||
};
|
||||
this.storageManager = storageManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the VLAN manager and load persisted mappings
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.storageManager) {
|
||||
await this.loadMappings();
|
||||
}
|
||||
logger.log('info', `VlanManager initialized with ${this.mappings.size} mappings, default VLAN: ${this.config.defaultVlan}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a MAC address to lowercase with colons
|
||||
* Accepts formats: 00:11:22:33:44:55, 00-11-22-33-44-55, 001122334455
|
||||
*/
|
||||
normalizeMac(mac: string): string {
|
||||
// Check cache first
|
||||
const cached = this.normalizedMacCache.get(mac);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Remove all separators and convert to lowercase
|
||||
const cleaned = mac.toLowerCase().replace(/[-:]/g, '');
|
||||
|
||||
// Format with colons
|
||||
const normalized = cleaned.match(/.{1,2}/g)?.join(':') || mac.toLowerCase();
|
||||
|
||||
// Cache the result
|
||||
this.normalizedMacCache.set(mac, normalized);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MAC address matches a pattern
|
||||
* Supports:
|
||||
* - Exact match: "00:11:22:33:44:55"
|
||||
* - OUI match: "00:11:22" (matches any device with this vendor prefix)
|
||||
* - Wildcard: "*" (matches all)
|
||||
*/
|
||||
macMatchesPattern(mac: string, pattern: string): boolean {
|
||||
const normalizedMac = this.normalizeMac(mac);
|
||||
const normalizedPattern = this.normalizeMac(pattern);
|
||||
|
||||
// Wildcard matches all
|
||||
if (pattern === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Exact match
|
||||
if (normalizedMac === normalizedPattern) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// OUI/prefix match (pattern is shorter than full MAC)
|
||||
if (normalizedPattern.length < 17 && normalizedMac.startsWith(normalizedPattern)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a MAC to VLAN mapping
|
||||
*/
|
||||
async addMapping(mapping: Omit<IMacVlanMapping, 'createdAt' | 'updatedAt'>): Promise<IMacVlanMapping> {
|
||||
const normalizedMac = this.normalizeMac(mapping.mac);
|
||||
const now = Date.now();
|
||||
|
||||
const existingMapping = this.mappings.get(normalizedMac);
|
||||
const fullMapping: IMacVlanMapping = {
|
||||
...mapping,
|
||||
mac: normalizedMac,
|
||||
createdAt: existingMapping?.createdAt || now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.mappings.set(normalizedMac, fullMapping);
|
||||
|
||||
// Persist to storage
|
||||
if (this.storageManager) {
|
||||
await this.saveMappings();
|
||||
}
|
||||
|
||||
logger.log('info', `VLAN mapping ${existingMapping ? 'updated' : 'added'}: ${normalizedMac} -> VLAN ${mapping.vlan}`);
|
||||
return fullMapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a MAC to VLAN mapping
|
||||
*/
|
||||
async removeMapping(mac: string): Promise<boolean> {
|
||||
const normalizedMac = this.normalizeMac(mac);
|
||||
const removed = this.mappings.delete(normalizedMac);
|
||||
|
||||
if (removed && this.storageManager) {
|
||||
await this.saveMappings();
|
||||
logger.log('info', `VLAN mapping removed: ${normalizedMac}`);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific mapping by MAC
|
||||
*/
|
||||
getMapping(mac: string): IMacVlanMapping | undefined {
|
||||
return this.mappings.get(this.normalizeMac(mac));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all mappings
|
||||
*/
|
||||
getAllMappings(): IMacVlanMapping[] {
|
||||
return Array.from(this.mappings.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine VLAN assignment for a MAC address
|
||||
* Returns the most specific matching rule (exact > OUI > wildcard > default)
|
||||
*/
|
||||
assignVlan(mac: string): IVlanAssignmentResult {
|
||||
const normalizedMac = this.normalizeMac(mac);
|
||||
|
||||
// First, try exact match
|
||||
const exactMatch = this.mappings.get(normalizedMac);
|
||||
if (exactMatch && exactMatch.enabled) {
|
||||
return {
|
||||
assigned: true,
|
||||
vlan: exactMatch.vlan,
|
||||
matchedRule: exactMatch,
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Try OUI/prefix matches (sorted by specificity - longer patterns first)
|
||||
const patternMatches: IMacVlanMapping[] = [];
|
||||
for (const mapping of this.mappings.values()) {
|
||||
if (mapping.enabled && mapping.mac !== normalizedMac && this.macMatchesPattern(normalizedMac, mapping.mac)) {
|
||||
patternMatches.push(mapping);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by pattern length (most specific first)
|
||||
patternMatches.sort((a, b) => b.mac.length - a.mac.length);
|
||||
|
||||
if (patternMatches.length > 0) {
|
||||
const bestMatch = patternMatches[0];
|
||||
return {
|
||||
assigned: true,
|
||||
vlan: bestMatch.vlan,
|
||||
matchedRule: bestMatch,
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
// No match - use default VLAN if allowed
|
||||
if (this.config.allowUnknownMacs) {
|
||||
return {
|
||||
assigned: true,
|
||||
vlan: this.config.defaultVlan,
|
||||
isDefault: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown MAC and not allowed
|
||||
return {
|
||||
assigned: false,
|
||||
vlan: 0,
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk import mappings
|
||||
*/
|
||||
async importMappings(mappings: Array<Omit<IMacVlanMapping, 'createdAt' | 'updatedAt'>>): Promise<number> {
|
||||
let imported = 0;
|
||||
|
||||
for (const mapping of mappings) {
|
||||
await this.addMapping(mapping);
|
||||
imported++;
|
||||
}
|
||||
|
||||
logger.log('info', `Imported ${imported} VLAN mappings`);
|
||||
return imported;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all mappings
|
||||
*/
|
||||
exportMappings(): IMacVlanMapping[] {
|
||||
return this.getAllMappings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
updateConfig(config: Partial<IVlanManagerConfig>): void {
|
||||
if (config.defaultVlan !== undefined) {
|
||||
this.config.defaultVlan = config.defaultVlan;
|
||||
}
|
||||
if (config.allowUnknownMacs !== undefined) {
|
||||
this.config.allowUnknownMacs = config.allowUnknownMacs;
|
||||
}
|
||||
logger.log('info', `VlanManager config updated: defaultVlan=${this.config.defaultVlan}, allowUnknown=${this.config.allowUnknownMacs}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
getConfig(): Required<IVlanManagerConfig> {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics
|
||||
*/
|
||||
getStats(): {
|
||||
totalMappings: number;
|
||||
enabledMappings: number;
|
||||
exactMatches: number;
|
||||
ouiPatterns: number;
|
||||
wildcardPatterns: number;
|
||||
} {
|
||||
let exactMatches = 0;
|
||||
let ouiPatterns = 0;
|
||||
let wildcardPatterns = 0;
|
||||
let enabledMappings = 0;
|
||||
|
||||
for (const mapping of this.mappings.values()) {
|
||||
if (mapping.enabled) {
|
||||
enabledMappings++;
|
||||
}
|
||||
|
||||
if (mapping.mac === '*') {
|
||||
wildcardPatterns++;
|
||||
} else if (mapping.mac.length < 17) {
|
||||
// OUI patterns are shorter than full MAC (17 chars with colons)
|
||||
ouiPatterns++;
|
||||
} else {
|
||||
exactMatches++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalMappings: this.mappings.size,
|
||||
enabledMappings,
|
||||
exactMatches,
|
||||
ouiPatterns,
|
||||
wildcardPatterns,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load mappings from storage
|
||||
*/
|
||||
private async loadMappings(): Promise<void> {
|
||||
if (!this.storageManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await this.storageManager.getJSON<IMacVlanMapping[]>(this.config.storagePrefix);
|
||||
if (data && Array.isArray(data)) {
|
||||
for (const mapping of data) {
|
||||
this.mappings.set(this.normalizeMac(mapping.mac), mapping);
|
||||
}
|
||||
logger.log('info', `Loaded ${data.length} VLAN mappings from storage`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('warn', `Failed to load VLAN mappings from storage: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save mappings to storage
|
||||
*/
|
||||
private async saveMappings(): Promise<void> {
|
||||
if (!this.storageManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const mappings = Array.from(this.mappings.values());
|
||||
await this.storageManager.setJSON(this.config.storagePrefix, mappings);
|
||||
} catch (error) {
|
||||
logger.log('error', `Failed to save VLAN mappings to storage: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
14
ts/radius/index.ts
Normal file
14
ts/radius/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* RADIUS module for DcRouter
|
||||
*
|
||||
* Provides:
|
||||
* - MAC Authentication Bypass (MAB) for network device authentication
|
||||
* - VLAN assignment based on MAC addresses
|
||||
* - OUI (vendor prefix) pattern matching for device categorization
|
||||
* - RADIUS accounting for session tracking and billing
|
||||
* - Integration with StorageManager for persistence
|
||||
*/
|
||||
|
||||
export * from './classes.radius.server.js';
|
||||
export * from './classes.vlan.manager.js';
|
||||
export * from './classes.accounting.manager.js';
|
||||
Reference in New Issue
Block a user