533 lines
16 KiB
TypeScript
533 lines
16 KiB
TypeScript
|
|
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;
|
||
|
|
}
|
||
|
|
}
|