feat(radius): add RADIUS server with MAC authentication (MAB), VLAN assignment, accounting and OpsServer API handlers

This commit is contained in:
2026-02-01 19:21:37 +00:00
parent fcea194cf6
commit c2d3ace0dd
31 changed files with 2498 additions and 531 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '2.12.6',
version: '2.13.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -14,6 +14,7 @@ import { StorageManager, type IStorageConfig } from './storage/index.js';
import { OpsServer } from './opsserver/index.js';
import { MetricsManager } from './monitoring/index.js';
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
export interface IDcRouterOptions {
/**
@@ -109,6 +110,12 @@ export interface IDcRouterOptions {
/** Storage configuration */
storage?: IStorageConfig;
/**
* RADIUS server configuration for network authentication
* Enables MAC Authentication Bypass (MAB) and VLAN assignment
*/
radiusConfig?: IRadiusServerConfig;
}
/**
@@ -132,6 +139,7 @@ export class DcRouter {
public smartProxy?: plugins.smartproxy.SmartProxy;
public dnsServer?: plugins.smartdns.dnsServerMod.DnsServer;
public emailServer?: UnifiedEmailServer;
public radiusServer?: RadiusServer;
public storageManager: StorageManager;
public opsServer: OpsServer;
public metricsManager?: MetricsManager;
@@ -181,11 +189,16 @@ export class DcRouter {
}
// Set up DNS server if configured with nameservers and scopes
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0 &&
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0 &&
this.options.dnsScopes && this.options.dnsScopes.length > 0) {
await this.setupDnsWithSocketHandler();
}
// Set up RADIUS server if configured
if (this.options.radiusConfig) {
await this.setupRadiusServer();
}
this.logStartupSummary();
} catch (error) {
console.error('❌ Error starting DcRouter:', error);
@@ -261,12 +274,23 @@ export class DcRouter {
}
}
// RADIUS service summary
if (this.radiusServer && this.options.radiusConfig) {
console.log('\n🔐 RADIUS Service:');
console.log(` ├─ Auth Port: ${this.options.radiusConfig.authPort || 1812}`);
console.log(` ├─ Acct Port: ${this.options.radiusConfig.acctPort || 1813}`);
console.log(` ├─ Clients configured: ${this.options.radiusConfig.clients?.length || 0}`);
const vlanStats = this.radiusServer.getVlanManager().getStats();
console.log(` ├─ VLAN mappings: ${vlanStats.totalMappings}`);
console.log(` └─ Accounting: ${this.options.radiusConfig.accounting?.enabled ? 'Enabled' : 'Disabled'}`);
}
// Storage summary
if (this.storageManager && this.options.storage) {
console.log('\n💾 Storage:');
console.log(` └─ Path: ${this.options.storage.fsPath || 'default'}`);
}
console.log('\n✅ All services are running\n');
}
@@ -582,16 +606,21 @@ export class DcRouter {
await Promise.all([
// Stop metrics manager if running
this.metricsManager ? this.metricsManager.stop().catch(err => console.error('Error stopping MetricsManager:', err)) : Promise.resolve(),
// Stop unified email server if running
this.emailServer ? this.emailServer.stop().catch(err => console.error('Error stopping email server:', err)) : Promise.resolve(),
// Stop HTTP SmartProxy if running
this.smartProxy ? this.smartProxy.stop().catch(err => console.error('Error stopping SmartProxy:', err)) : Promise.resolve(),
// Stop DNS server if running
this.dnsServer ?
this.dnsServer.stop().catch(err => console.error('Error stopping DNS server:', err)) :
this.dnsServer ?
this.dnsServer.stop().catch(err => console.error('Error stopping DNS server:', err)) :
Promise.resolve(),
// Stop RADIUS server if running
this.radiusServer ?
this.radiusServer.stop().catch(err => console.error('Error stopping RADIUS server:', err)) :
Promise.resolve()
]);
@@ -1338,9 +1367,47 @@ export class DcRouter {
}
};
}
/**
* Set up RADIUS server for network authentication
*/
private async setupRadiusServer(): Promise<void> {
if (!this.options.radiusConfig) {
return;
}
logger.log('info', 'Setting up RADIUS server...');
this.radiusServer = new RadiusServer(this.options.radiusConfig, this.storageManager);
await this.radiusServer.start();
logger.log('info', `RADIUS server started on ports ${this.options.radiusConfig.authPort || 1812} (auth) and ${this.options.radiusConfig.acctPort || 1813} (acct)`);
}
/**
* Update RADIUS configuration at runtime
*/
public async updateRadiusConfig(config: IRadiusServerConfig): Promise<void> {
// Stop existing RADIUS server if running
if (this.radiusServer) {
await this.radiusServer.stop();
this.radiusServer = undefined;
}
// Update configuration
this.options.radiusConfig = config;
// Start with new configuration
await this.setupRadiusServer();
logger.log('info', 'RADIUS configuration updated');
}
}
// Re-export email server types for convenience
export type { IUnifiedEmailServerOptions };
// Re-export RADIUS types for convenience
export type { IRadiusServerConfig };
export default DcRouter;

View File

@@ -4,4 +4,7 @@ export * from './mail/index.js';
// DcRouter
export * from './classes.dcrouter.js';
// RADIUS module
export * from './radius/index.js';
export const runCli = async () => {}

View File

@@ -16,6 +16,7 @@ export class OpsServer {
private logsHandler: handlers.LogsHandler;
private securityHandler: handlers.SecurityHandler;
private statsHandler: handlers.StatsHandler;
private radiusHandler: handlers.RadiusHandler;
constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg;
@@ -53,7 +54,8 @@ export class OpsServer {
this.logsHandler = new handlers.LogsHandler(this);
this.securityHandler = new handlers.SecurityHandler(this);
this.statsHandler = new handlers.StatsHandler(this);
this.radiusHandler = new handlers.RadiusHandler(this);
console.log('✅ OpsServer TypedRequest handlers initialized');
}

View File

@@ -2,4 +2,5 @@ export * from './admin.handler.js';
export * from './config.handler.js';
export * from './logs.handler.js';
export * from './security.handler.js';
export * from './stats.handler.js';
export * from './stats.handler.js';
export * from './radius.handler.js';

View File

@@ -0,0 +1,405 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
export class RadiusHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
// Add this handler's router to the parent
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
// ========================================================================
// RADIUS Client Management
// ========================================================================
// Get all RADIUS clients
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusClients>(
'getRadiusClients',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return { clients: [] };
}
const clients = radiusServer.getClients();
return {
clients: clients.map(c => ({
name: c.name,
ipRange: c.ipRange,
description: c.description,
enabled: c.enabled,
})),
};
}
)
);
// Add or update a RADIUS client
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetRadiusClient>(
'setRadiusClient',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return { success: false, message: 'RADIUS server not configured' };
}
try {
await radiusServer.addClient(dataArg.client);
return { success: true };
} catch (error) {
return { success: false, message: error.message };
}
}
)
);
// Remove a RADIUS client
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveRadiusClient>(
'removeRadiusClient',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return { success: false, message: 'RADIUS server not configured' };
}
const removed = radiusServer.removeClient(dataArg.name);
return {
success: removed,
message: removed ? undefined : 'Client not found',
};
}
)
);
// ========================================================================
// VLAN Mapping Management
// ========================================================================
// Get all VLAN mappings
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetVlanMappings>(
'getVlanMappings',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return {
mappings: [],
config: { defaultVlan: 1, allowUnknownMacs: true },
};
}
const vlanManager = radiusServer.getVlanManager();
const mappings = vlanManager.getAllMappings();
const config = vlanManager.getConfig();
return {
mappings: mappings.map(m => ({
mac: m.mac,
vlan: m.vlan,
description: m.description,
enabled: m.enabled,
createdAt: m.createdAt,
updatedAt: m.updatedAt,
})),
config: {
defaultVlan: config.defaultVlan,
allowUnknownMacs: config.allowUnknownMacs,
},
};
}
)
);
// Add or update a VLAN mapping
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SetVlanMapping>(
'setVlanMapping',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return { success: false, message: 'RADIUS server not configured' };
}
try {
const vlanManager = radiusServer.getVlanManager();
const mapping = await vlanManager.addMapping(dataArg.mapping);
return {
success: true,
mapping: {
mac: mapping.mac,
vlan: mapping.vlan,
description: mapping.description,
enabled: mapping.enabled,
createdAt: mapping.createdAt,
updatedAt: mapping.updatedAt,
},
};
} catch (error) {
return { success: false, message: error.message };
}
}
)
);
// Remove a VLAN mapping
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveVlanMapping>(
'removeVlanMapping',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return { success: false, message: 'RADIUS server not configured' };
}
const vlanManager = radiusServer.getVlanManager();
const removed = await vlanManager.removeMapping(dataArg.mac);
return {
success: removed,
message: removed ? undefined : 'Mapping not found',
};
}
)
);
// Update VLAN configuration
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateVlanConfig>(
'updateVlanConfig',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return {
success: false,
config: { defaultVlan: 1, allowUnknownMacs: true },
};
}
const vlanManager = radiusServer.getVlanManager();
vlanManager.updateConfig({
defaultVlan: dataArg.defaultVlan,
allowUnknownMacs: dataArg.allowUnknownMacs,
});
const config = vlanManager.getConfig();
return {
success: true,
config: {
defaultVlan: config.defaultVlan,
allowUnknownMacs: config.allowUnknownMacs,
},
};
}
)
);
// Test VLAN assignment
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_TestVlanAssignment>(
'testVlanAssignment',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return { assigned: false, vlan: 0, isDefault: false };
}
const vlanManager = radiusServer.getVlanManager();
const result = vlanManager.assignVlan(dataArg.mac);
return {
assigned: result.assigned,
vlan: result.vlan,
isDefault: result.isDefault,
matchedRule: result.matchedRule
? {
mac: result.matchedRule.mac,
vlan: result.matchedRule.vlan,
description: result.matchedRule.description,
}
: undefined,
};
}
)
);
// ========================================================================
// Accounting / Session Management
// ========================================================================
// Get active sessions
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusSessions>(
'getRadiusSessions',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return { sessions: [], totalCount: 0 };
}
const accountingManager = radiusServer.getAccountingManager();
let sessions = accountingManager.getActiveSessions();
// Apply filters
if (dataArg.filter) {
if (dataArg.filter.username) {
sessions = sessions.filter(s => s.username === dataArg.filter!.username);
}
if (dataArg.filter.nasIpAddress) {
sessions = sessions.filter(s => s.nasIpAddress === dataArg.filter!.nasIpAddress);
}
if (dataArg.filter.vlanId !== undefined) {
sessions = sessions.filter(s => s.vlanId === dataArg.filter!.vlanId);
}
}
return {
sessions: sessions.map(s => ({
sessionId: s.sessionId,
username: s.username,
macAddress: s.macAddress,
nasIpAddress: s.nasIpAddress,
nasIdentifier: s.nasIdentifier,
vlanId: s.vlanId,
framedIpAddress: s.framedIpAddress,
startTime: s.startTime,
lastUpdateTime: s.lastUpdateTime,
status: s.status,
inputOctets: s.inputOctets,
outputOctets: s.outputOctets,
sessionTime: s.sessionTime,
})),
totalCount: sessions.length,
};
}
)
);
// Disconnect a session
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DisconnectRadiusSession>(
'disconnectRadiusSession',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return { success: false, message: 'RADIUS server not configured' };
}
const accountingManager = radiusServer.getAccountingManager();
const disconnected = await accountingManager.disconnectSession(
dataArg.sessionId,
dataArg.reason || 'AdminReset'
);
return {
success: disconnected,
message: disconnected ? undefined : 'Session not found',
};
}
)
);
// Get accounting summary
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusAccountingSummary>(
'getRadiusAccountingSummary',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return {
summary: {
periodStart: dataArg.startTime,
periodEnd: dataArg.endTime,
totalSessions: 0,
activeSessions: 0,
totalInputBytes: 0,
totalOutputBytes: 0,
totalSessionTime: 0,
averageSessionDuration: 0,
uniqueUsers: 0,
sessionsByVlan: {},
topUsersByTraffic: [],
},
};
}
const accountingManager = radiusServer.getAccountingManager();
const summary = await accountingManager.getSummary(dataArg.startTime, dataArg.endTime);
return { summary };
}
)
);
// ========================================================================
// Statistics
// ========================================================================
// Get RADIUS statistics
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRadiusStatistics>(
'getRadiusStatistics',
async (dataArg, toolsArg) => {
const radiusServer = this.opsServerRef.dcRouterRef.radiusServer;
if (!radiusServer) {
return {
stats: {
running: false,
uptime: 0,
authRequests: 0,
authAccepts: 0,
authRejects: 0,
accountingRequests: 0,
activeSessions: 0,
vlanMappings: 0,
clients: 0,
},
vlanStats: {
totalMappings: 0,
enabledMappings: 0,
exactMatches: 0,
ouiPatterns: 0,
wildcardPatterns: 0,
},
accountingStats: {
activeSessions: 0,
totalSessionsStarted: 0,
totalSessionsStopped: 0,
totalInputBytes: 0,
totalOutputBytes: 0,
interimUpdatesReceived: 0,
},
};
}
const stats = radiusServer.getStats();
const vlanStats = radiusServer.getVlanManager().getStats();
const accountingStats = radiusServer.getAccountingManager().getStats();
return {
stats,
vlanStats,
accountingStats,
};
}
)
);
}
}

View File

@@ -55,12 +55,13 @@ import * as smartnetwork from '@push.rocks/smartnetwork';
import * as smartpath from '@push.rocks/smartpath';
import * as smartproxy from '@push.rocks/smartproxy';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartradius from '@push.rocks/smartradius';
import * as smartrequest from '@push.rocks/smartrequest';
import * as smartrule from '@push.rocks/smartrule';
import * as smartrx from '@push.rocks/smartrx';
import * as smartunique from '@push.rocks/smartunique';
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmail, smartmetrics, smartnetwork, smartpath, smartproxy, smartpromise, smartrequest, smartrule, smartrx, smartunique };
export { projectinfo, qenv, smartacme, smartdata, smartdns, smartfile, smartguard, smartjwt, smartlog, smartmail, smartmetrics, smartnetwork, smartpath, smartproxy, smartpromise, smartradius, smartrequest, smartrule, smartrx, smartunique };
// Define SmartLog types for use in error handling
export type TLogLevel = 'error' | 'warn' | 'info' | 'success' | 'debug';

View 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;
}
}

View 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;
}
}

View 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
View 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';