import * as plugins from './plugins.js'; import type { IRadiusServerOptions, IRadiusServerStats, IRadiusPacket, IAuthenticationRequest, IAuthenticationResponse, IAccountingRequest, IAccountingResponse, TAuthenticationHandler, TAccountingHandler, } from './interfaces.js'; import { ERadiusCode, ERadiusAttributeType, EAcctStatusType, ENasPortType, EServiceType, } from './interfaces.js'; import { RadiusPacket } from './classes.radiuspacket.js'; import { RadiusAttributes } from './classes.radiusattributes.js'; import { RadiusAuthenticator } from './classes.radiusauthenticator.js'; import { RadiusSecrets } from './classes.radiussecrets.js'; /** * RADIUS Server implementation * Implements RFC 2865 (Authentication) and RFC 2866 (Accounting) */ export class RadiusServer { private authSocket?: plugins.dgram.Socket; private acctSocket?: plugins.dgram.Socket; private readonly secrets: RadiusSecrets; private authenticationHandler?: TAuthenticationHandler; private accountingHandler?: TAccountingHandler; private readonly options: Required< Pick >; private readonly stats: IRadiusServerStats = { authRequests: 0, authAccepts: 0, authRejects: 0, authChallenges: 0, authInvalidPackets: 0, authUnknownClients: 0, acctRequests: 0, acctResponses: 0, acctInvalidPackets: 0, acctUnknownClients: 0, }; // Duplicate detection cache: key = clientIp:clientPort:identifier private readonly recentRequests: Map = new Map(); private duplicateCleanupInterval?: ReturnType; constructor(options: IRadiusServerOptions = {}) { this.options = { authPort: options.authPort ?? 1812, acctPort: options.acctPort ?? 1813, bindAddress: options.bindAddress ?? '0.0.0.0', duplicateDetectionWindow: options.duplicateDetectionWindow ?? 10000, // 10 seconds maxPacketSize: options.maxPacketSize ?? RadiusPacket.MAX_PACKET_SIZE, }; this.secrets = new RadiusSecrets({ defaultSecret: options.defaultSecret, resolver: options.secretResolver, }); if (options.authenticationHandler) { this.authenticationHandler = options.authenticationHandler; } if (options.accountingHandler) { this.accountingHandler = options.accountingHandler; } } /** * Start the RADIUS server */ public async start(): Promise { await Promise.all([ this.startAuthServer(), this.startAcctServer(), ]); // Start duplicate detection cleanup this.duplicateCleanupInterval = setInterval(() => { this.cleanupDuplicateCache(); }, this.options.duplicateDetectionWindow); } /** * Stop the RADIUS server */ public async stop(): Promise { if (this.duplicateCleanupInterval) { clearInterval(this.duplicateCleanupInterval); this.duplicateCleanupInterval = undefined; } const stopPromises: Promise[] = []; if (this.authSocket) { stopPromises.push(new Promise((resolve) => { this.authSocket!.close(() => { this.authSocket = undefined; resolve(); }); })); } if (this.acctSocket) { stopPromises.push(new Promise((resolve) => { this.acctSocket!.close(() => { this.acctSocket = undefined; resolve(); }); })); } await Promise.all(stopPromises); } /** * Set the authentication handler */ public setAuthenticationHandler(handler: TAuthenticationHandler): void { this.authenticationHandler = handler; } /** * Set the accounting handler */ public setAccountingHandler(handler: TAccountingHandler): void { this.accountingHandler = handler; } /** * Set secret for a client */ public setClientSecret(clientIp: string, secret: string): void { this.secrets.setClientSecret(clientIp, secret); } /** * Get server statistics */ public getStats(): IRadiusServerStats { return { ...this.stats }; } /** * Reset server statistics */ public resetStats(): void { this.stats.authRequests = 0; this.stats.authAccepts = 0; this.stats.authRejects = 0; this.stats.authChallenges = 0; this.stats.authInvalidPackets = 0; this.stats.authUnknownClients = 0; this.stats.acctRequests = 0; this.stats.acctResponses = 0; this.stats.acctInvalidPackets = 0; this.stats.acctUnknownClients = 0; } private async startAuthServer(): Promise { return new Promise((resolve, reject) => { this.authSocket = plugins.dgram.createSocket('udp4'); this.authSocket.on('error', (err) => { reject(err); }); this.authSocket.on('message', (msg, rinfo) => { this.handleAuthMessage(msg, rinfo).catch((err) => { console.error('Error handling auth message:', err); }); }); this.authSocket.bind(this.options.authPort, this.options.bindAddress, () => { resolve(); }); }); } private async startAcctServer(): Promise { return new Promise((resolve, reject) => { this.acctSocket = plugins.dgram.createSocket('udp4'); this.acctSocket.on('error', (err) => { reject(err); }); this.acctSocket.on('message', (msg, rinfo) => { this.handleAcctMessage(msg, rinfo).catch((err) => { console.error('Error handling acct message:', err); }); }); this.acctSocket.bind(this.options.acctPort, this.options.bindAddress, () => { resolve(); }); }); } private async handleAuthMessage(msg: Buffer, rinfo: plugins.dgram.RemoteInfo): Promise { // Validate packet size if (msg.length > this.options.maxPacketSize) { this.stats.authInvalidPackets++; return; } // Get client secret const secret = this.secrets.getSecret(rinfo.address); if (!secret) { this.stats.authUnknownClients++; return; } // Parse packet let packet: IRadiusPacket; try { packet = RadiusPacket.decode(msg); } catch (err) { this.stats.authInvalidPackets++; return; } // Only handle Access-Request if (packet.code !== ERadiusCode.AccessRequest) { this.stats.authInvalidPackets++; return; } this.stats.authRequests++; // Check for duplicate const duplicateKey = `${rinfo.address}:${rinfo.port}:${packet.identifier}`; const cached = this.recentRequests.get(duplicateKey); if (cached && cached.response) { // Retransmit cached response this.authSocket!.send(cached.response, rinfo.port, rinfo.address); return; } // Mark as in-progress (no response yet) this.recentRequests.set(duplicateKey, { timestamp: Date.now() }); // Build authentication request context const authRequest = this.buildAuthRequest(packet, secret, rinfo); // Call authentication handler let response: IAuthenticationResponse; if (this.authenticationHandler) { try { response = await this.authenticationHandler(authRequest); } catch (err) { response = { code: ERadiusCode.AccessReject, replyMessage: 'Internal server error', }; } } else { // No handler configured - reject all response = { code: ERadiusCode.AccessReject, replyMessage: 'Authentication service not configured', }; } // Build response packet const responsePacket = this.buildAuthResponse( response, packet.identifier, packet.authenticator, secret ); // Update stats switch (response.code) { case ERadiusCode.AccessAccept: this.stats.authAccepts++; break; case ERadiusCode.AccessReject: this.stats.authRejects++; break; case ERadiusCode.AccessChallenge: this.stats.authChallenges++; break; } // Cache response for duplicate detection this.recentRequests.set(duplicateKey, { timestamp: Date.now(), response: responsePacket, }); // Send response this.authSocket!.send(responsePacket, rinfo.port, rinfo.address); } private async handleAcctMessage(msg: Buffer, rinfo: plugins.dgram.RemoteInfo): Promise { // Validate packet size if (msg.length > this.options.maxPacketSize) { this.stats.acctInvalidPackets++; return; } // Get client secret const secret = this.secrets.getSecret(rinfo.address); if (!secret) { this.stats.acctUnknownClients++; return; } // Verify accounting request authenticator if (!RadiusAuthenticator.verifyAccountingRequestAuthenticator(msg, secret)) { this.stats.acctInvalidPackets++; return; } // Parse packet let packet: IRadiusPacket; try { packet = RadiusPacket.decode(msg); } catch (err) { this.stats.acctInvalidPackets++; return; } // Only handle Accounting-Request if (packet.code !== ERadiusCode.AccountingRequest) { this.stats.acctInvalidPackets++; return; } this.stats.acctRequests++; // Check for duplicate const duplicateKey = `acct:${rinfo.address}:${rinfo.port}:${packet.identifier}`; const cached = this.recentRequests.get(duplicateKey); if (cached && cached.response) { // Retransmit cached response this.acctSocket!.send(cached.response, rinfo.port, rinfo.address); return; } // Mark as in-progress this.recentRequests.set(duplicateKey, { timestamp: Date.now() }); // Build accounting request context const acctRequest = this.buildAcctRequest(packet, rinfo); // Call accounting handler let response: IAccountingResponse; if (this.accountingHandler) { try { response = await this.accountingHandler(acctRequest); } catch (err) { // Don't respond if we can't record the accounting data return; } } else { // No handler configured - accept all response = { success: true }; } // Only respond if accounting was successful if (!response.success) { return; } // Build response packet const responsePacket = RadiusPacket.createAccountingResponse( packet.identifier, packet.authenticator, secret, response.attributes || [] ); this.stats.acctResponses++; // Cache response for duplicate detection this.recentRequests.set(duplicateKey, { timestamp: Date.now(), response: responsePacket, }); // Send response this.acctSocket!.send(responsePacket, rinfo.port, rinfo.address); } private buildAuthRequest( packet: IRadiusPacket, secret: string, rinfo: plugins.dgram.RemoteInfo ): IAuthenticationRequest { const getUsernameAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.UserName); const username = getUsernameAttr ? getUsernameAttr.toString('utf8') : ''; // Decrypt PAP password if present const passwordAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.UserPassword); let password: string | undefined; if (passwordAttr) { try { password = RadiusAuthenticator.decryptPassword(passwordAttr, packet.authenticator, secret); } catch { password = undefined; } } // Get CHAP attributes const chapPassword = RadiusPacket.getAttribute(packet, ERadiusAttributeType.ChapPassword); let chapChallenge = RadiusPacket.getAttribute(packet, ERadiusAttributeType.ChapChallenge); // If no CHAP-Challenge attribute, use Request Authenticator as challenge if (chapPassword && !chapChallenge) { chapChallenge = packet.authenticator; } // Get NAS attributes const nasIpAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.NasIpAddress); const nasIpAddress = nasIpAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.NasIpAddress, nasIpAttr) as string : undefined; const nasIdAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.NasIdentifier); const nasIdentifier = nasIdAttr ? nasIdAttr.toString('utf8') : undefined; const nasPortAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.NasPort); const nasPort = nasPortAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.NasPort, nasPortAttr) as number : undefined; const nasPortTypeAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.NasPortType); const nasPortType = nasPortTypeAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.NasPortType, nasPortTypeAttr) as ENasPortType : undefined; // Get other common attributes const calledStationIdAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.CalledStationId); const calledStationId = calledStationIdAttr ? calledStationIdAttr.toString('utf8') : undefined; const callingStationIdAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.CallingStationId); const callingStationId = callingStationIdAttr ? callingStationIdAttr.toString('utf8') : undefined; const serviceTypeAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.ServiceType); const serviceType = serviceTypeAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.ServiceType, serviceTypeAttr) as EServiceType : undefined; const framedProtocolAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.FramedProtocol); const framedProtocol = framedProtocolAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.FramedProtocol, framedProtocolAttr) as number : undefined; const stateAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.State); return { username, password, chapPassword, chapChallenge, nasIpAddress, nasIdentifier, nasPort, nasPortType, calledStationId, callingStationId, serviceType, framedProtocol, state: stateAttr, rawPacket: packet, clientAddress: rinfo.address, clientPort: rinfo.port, }; } private buildAcctRequest( packet: IRadiusPacket, rinfo: plugins.dgram.RemoteInfo ): IAccountingRequest { const statusTypeAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctStatusType); const statusType = statusTypeAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.AcctStatusType, statusTypeAttr) as EAcctStatusType : EAcctStatusType.Start; const sessionIdAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctSessionId); const sessionId = sessionIdAttr ? sessionIdAttr.toString('utf8') : ''; const usernameAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.UserName); const username = usernameAttr ? usernameAttr.toString('utf8') : undefined; const nasIpAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.NasIpAddress); const nasIpAddress = nasIpAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.NasIpAddress, nasIpAttr) as string : undefined; const nasIdAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.NasIdentifier); const nasIdentifier = nasIdAttr ? nasIdAttr.toString('utf8') : undefined; const nasPortAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.NasPort); const nasPort = nasPortAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.NasPort, nasPortAttr) as number : undefined; const nasPortTypeAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.NasPortType); const nasPortType = nasPortTypeAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.NasPortType, nasPortTypeAttr) as ENasPortType : undefined; const delayTimeAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctDelayTime); const delayTime = delayTimeAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.AcctDelayTime, delayTimeAttr) as number : undefined; const inputOctetsAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctInputOctets); const inputOctets = inputOctetsAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.AcctInputOctets, inputOctetsAttr) as number : undefined; const outputOctetsAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctOutputOctets); const outputOctets = outputOctetsAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.AcctOutputOctets, outputOctetsAttr) as number : undefined; const sessionTimeAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctSessionTime); const sessionTime = sessionTimeAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.AcctSessionTime, sessionTimeAttr) as number : undefined; const inputPacketsAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctInputPackets); const inputPackets = inputPacketsAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.AcctInputPackets, inputPacketsAttr) as number : undefined; const outputPacketsAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctOutputPackets); const outputPackets = outputPacketsAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.AcctOutputPackets, outputPacketsAttr) as number : undefined; const terminateCauseAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctTerminateCause); const terminateCause = terminateCauseAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.AcctTerminateCause, terminateCauseAttr) as number : undefined; const authenticAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctAuthentic); const authentic = authenticAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.AcctAuthentic, authenticAttr) as number : undefined; const multiSessionIdAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctMultiSessionId); const multiSessionId = multiSessionIdAttr ? multiSessionIdAttr.toString('utf8') : undefined; const linkCountAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctLinkCount); const linkCount = linkCountAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.AcctLinkCount, linkCountAttr) as number : undefined; const calledStationIdAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.CalledStationId); const calledStationId = calledStationIdAttr ? calledStationIdAttr.toString('utf8') : undefined; const callingStationIdAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.CallingStationId); const callingStationId = callingStationIdAttr ? callingStationIdAttr.toString('utf8') : undefined; return { statusType, sessionId, username, nasIpAddress, nasIdentifier, nasPort, nasPortType, delayTime, inputOctets, outputOctets, sessionTime, inputPackets, outputPackets, terminateCause, authentic, multiSessionId, linkCount, calledStationId, callingStationId, rawPacket: packet, clientAddress: rinfo.address, clientPort: rinfo.port, }; } private buildAuthResponse( response: IAuthenticationResponse, identifier: number, requestAuthenticator: Buffer, secret: string ): Buffer { const attributes: Array<{ type: number | string; value: string | number | Buffer }> = []; // Add reply message if present if (response.replyMessage) { attributes.push({ type: ERadiusAttributeType.ReplyMessage, value: response.replyMessage }); } // Add session timeout if present if (response.sessionTimeout !== undefined) { attributes.push({ type: ERadiusAttributeType.SessionTimeout, value: response.sessionTimeout }); } // Add idle timeout if present if (response.idleTimeout !== undefined) { attributes.push({ type: ERadiusAttributeType.IdleTimeout, value: response.idleTimeout }); } // Add state if present if (response.state) { attributes.push({ type: ERadiusAttributeType.State, value: response.state }); } // Add class if present if (response.class) { attributes.push({ type: ERadiusAttributeType.Class, value: response.class }); } // Add framed IP if present if (response.framedIpAddress) { attributes.push({ type: ERadiusAttributeType.FramedIpAddress, value: response.framedIpAddress }); } // Add framed IP netmask if present if (response.framedIpNetmask) { attributes.push({ type: ERadiusAttributeType.FramedIpNetmask, value: response.framedIpNetmask }); } // Add framed routes if present if (response.framedRoutes) { for (const route of response.framedRoutes) { attributes.push({ type: ERadiusAttributeType.FramedRoute, value: route }); } } // Add vendor attributes if present if (response.vendorAttributes) { for (const vsa of response.vendorAttributes) { const vsaBuffer = RadiusAttributes.encodeVSA(vsa); attributes.push({ type: ERadiusAttributeType.VendorSpecific, value: vsaBuffer }); } } // Add custom attributes if present if (response.attributes) { attributes.push(...response.attributes); } // Create response based on code switch (response.code) { case ERadiusCode.AccessAccept: return RadiusPacket.createAccessAccept(identifier, requestAuthenticator, secret, attributes); case ERadiusCode.AccessReject: return RadiusPacket.createAccessReject(identifier, requestAuthenticator, secret, attributes); case ERadiusCode.AccessChallenge: return RadiusPacket.createAccessChallenge(identifier, requestAuthenticator, secret, attributes); default: return RadiusPacket.createAccessReject(identifier, requestAuthenticator, secret, attributes); } } private cleanupDuplicateCache(): void { const now = Date.now(); const expiry = this.options.duplicateDetectionWindow; for (const [key, entry] of this.recentRequests) { if (now - entry.timestamp > expiry) { this.recentRequests.delete(key); } } } } export default RadiusServer;