import * as plugins from './plugins.js'; import type { IRadiusClientOptions, IClientAuthRequest, IClientAuthResponse, IClientAccountingRequest, IClientAccountingResponse, } from './interfaces.js'; import { RadiusPacket, RadiusAuthenticator, RadiusAttributes, ERadiusCode, ERadiusAttributeType, } from '../ts_server/index.js'; /** * Pending request tracking */ interface IPendingRequest { identifier: number; requestAuthenticator: Buffer; resolve: (response: Buffer) => void; reject: (error: Error) => void; timeoutId?: ReturnType; retries: number; packet: Buffer; port: number; } /** * RADIUS Client implementation * Supports PAP, CHAP authentication and accounting */ export class RadiusClient { private socket?: plugins.dgram.Socket; private readonly options: Required; private currentIdentifier = 0; private readonly pendingRequests: Map = new Map(); private isConnected = false; constructor(options: IRadiusClientOptions) { this.options = { host: options.host, authPort: options.authPort ?? 1812, acctPort: options.acctPort ?? 1813, secret: options.secret, timeout: options.timeout ?? 5000, retries: options.retries ?? 3, retryDelay: options.retryDelay ?? 1000, nasIpAddress: options.nasIpAddress ?? '0.0.0.0', nasIdentifier: options.nasIdentifier ?? 'smartradius-client', }; } /** * Connect the client (bind UDP socket) */ public async connect(): Promise { if (this.isConnected) { return; } return new Promise((resolve, reject) => { this.socket = plugins.dgram.createSocket('udp4'); this.socket.on('error', (err) => { if (!this.isConnected) { reject(err); } }); this.socket.on('message', (msg) => { this.handleResponse(msg); }); this.socket.bind(0, () => { this.isConnected = true; resolve(); }); }); } /** * Disconnect the client */ public async disconnect(): Promise { if (!this.isConnected || !this.socket) { return; } // Reject all pending requests for (const [, pending] of this.pendingRequests) { if (pending.timeoutId) { clearTimeout(pending.timeoutId); } pending.reject(new Error('Client disconnected')); } this.pendingRequests.clear(); return new Promise((resolve) => { this.socket!.close(() => { this.socket = undefined; this.isConnected = false; resolve(); }); }); } /** * Authenticate a user using PAP */ public async authenticatePap(username: string, password: string): Promise { return this.authenticate({ username, password, }); } /** * Authenticate a user using CHAP */ public async authenticateChap( username: string, password: string, challenge?: Buffer ): Promise { // Generate challenge if not provided const chapChallenge = challenge || plugins.crypto.randomBytes(16); const chapId = Math.floor(Math.random() * 256); // Calculate CHAP response const chapResponse = RadiusAuthenticator.calculateChapResponse(chapId, password, chapChallenge); // CHAP-Password = CHAP Ident (1 byte) + Response (16 bytes) const chapPassword = Buffer.allocUnsafe(17); chapPassword.writeUInt8(chapId, 0); chapResponse.copy(chapPassword, 1); return this.authenticate({ username, chapPassword, chapChallenge, }); } /** * Send an authentication request */ public async authenticate(request: IClientAuthRequest): Promise { await this.ensureConnected(); const identifier = this.nextIdentifier(); const attributes: Array<{ type: number | string; value: string | number | Buffer }> = []; // Add User-Name attributes.push({ type: ERadiusAttributeType.UserName, value: request.username }); // Add NAS-IP-Address or NAS-Identifier if (this.options.nasIpAddress && this.options.nasIpAddress !== '0.0.0.0') { attributes.push({ type: ERadiusAttributeType.NasIpAddress, value: this.options.nasIpAddress }); } if (this.options.nasIdentifier) { attributes.push({ type: ERadiusAttributeType.NasIdentifier, value: this.options.nasIdentifier }); } // Add PAP password or CHAP credentials if (request.password !== undefined) { // PAP - password will be encrypted in createAccessRequest attributes.push({ type: ERadiusAttributeType.UserPassword, value: request.password }); } else if (request.chapPassword) { // CHAP attributes.push({ type: ERadiusAttributeType.ChapPassword, value: request.chapPassword }); if (request.chapChallenge) { attributes.push({ type: ERadiusAttributeType.ChapChallenge, value: request.chapChallenge }); } } // Add optional attributes if (request.nasPort !== undefined) { attributes.push({ type: ERadiusAttributeType.NasPort, value: request.nasPort }); } if (request.nasPortType !== undefined) { attributes.push({ type: ERadiusAttributeType.NasPortType, value: request.nasPortType }); } if (request.serviceType !== undefined) { attributes.push({ type: ERadiusAttributeType.ServiceType, value: request.serviceType }); } if (request.calledStationId) { attributes.push({ type: ERadiusAttributeType.CalledStationId, value: request.calledStationId }); } if (request.callingStationId) { attributes.push({ type: ERadiusAttributeType.CallingStationId, value: request.callingStationId }); } if (request.state) { attributes.push({ type: ERadiusAttributeType.State, value: request.state }); } // Add custom attributes if (request.customAttributes) { attributes.push(...request.customAttributes); } // Create packet const packet = RadiusPacket.createAccessRequest(identifier, this.options.secret, attributes); // Extract request authenticator from packet (bytes 4-20) const requestAuthenticator = packet.subarray(4, 20); // Send and wait for response const responseBuffer = await this.sendRequest( identifier, packet, requestAuthenticator, this.options.authPort ); // Verify response authenticator if (!RadiusAuthenticator.verifyResponseAuthenticator( responseBuffer, requestAuthenticator, this.options.secret )) { throw new Error('Invalid response authenticator'); } // Parse response const response = RadiusPacket.decodeAndParse(responseBuffer); return this.buildAuthResponse(response); } /** * Send an accounting request */ public async accounting(request: IClientAccountingRequest): Promise { await this.ensureConnected(); const identifier = this.nextIdentifier(); const attributes: Array<{ type: number | string; value: string | number | Buffer }> = []; // Add required attributes attributes.push({ type: ERadiusAttributeType.AcctStatusType, value: request.statusType }); attributes.push({ type: ERadiusAttributeType.AcctSessionId, value: request.sessionId }); // Add NAS identification if (this.options.nasIpAddress && this.options.nasIpAddress !== '0.0.0.0') { attributes.push({ type: ERadiusAttributeType.NasIpAddress, value: this.options.nasIpAddress }); } if (this.options.nasIdentifier) { attributes.push({ type: ERadiusAttributeType.NasIdentifier, value: this.options.nasIdentifier }); } // Add optional attributes if (request.username) { attributes.push({ type: ERadiusAttributeType.UserName, value: request.username }); } if (request.nasPort !== undefined) { attributes.push({ type: ERadiusAttributeType.NasPort, value: request.nasPort }); } if (request.nasPortType !== undefined) { attributes.push({ type: ERadiusAttributeType.NasPortType, value: request.nasPortType }); } if (request.sessionTime !== undefined) { attributes.push({ type: ERadiusAttributeType.AcctSessionTime, value: request.sessionTime }); } if (request.inputOctets !== undefined) { attributes.push({ type: ERadiusAttributeType.AcctInputOctets, value: request.inputOctets }); } if (request.outputOctets !== undefined) { attributes.push({ type: ERadiusAttributeType.AcctOutputOctets, value: request.outputOctets }); } if (request.inputPackets !== undefined) { attributes.push({ type: ERadiusAttributeType.AcctInputPackets, value: request.inputPackets }); } if (request.outputPackets !== undefined) { attributes.push({ type: ERadiusAttributeType.AcctOutputPackets, value: request.outputPackets }); } if (request.terminateCause !== undefined) { attributes.push({ type: ERadiusAttributeType.AcctTerminateCause, value: request.terminateCause }); } if (request.calledStationId) { attributes.push({ type: ERadiusAttributeType.CalledStationId, value: request.calledStationId }); } if (request.callingStationId) { attributes.push({ type: ERadiusAttributeType.CallingStationId, value: request.callingStationId }); } // Add custom attributes if (request.customAttributes) { attributes.push(...request.customAttributes); } // Create packet const packet = RadiusPacket.createAccountingRequest(identifier, this.options.secret, attributes); // Extract request authenticator from packet const requestAuthenticator = packet.subarray(4, 20); // Send and wait for response const responseBuffer = await this.sendRequest( identifier, packet, requestAuthenticator, this.options.acctPort ); // Verify response authenticator if (!RadiusAuthenticator.verifyResponseAuthenticator( responseBuffer, requestAuthenticator, this.options.secret )) { throw new Error('Invalid response authenticator'); } // Parse response const response = RadiusPacket.decodeAndParse(responseBuffer); return { success: response.code === ERadiusCode.AccountingResponse, attributes: response.parsedAttributes, rawPacket: response, }; } /** * Send accounting start */ public async accountingStart(sessionId: string, username?: string): Promise { const { EAcctStatusType } = await import('../ts_server/index.js'); return this.accounting({ statusType: EAcctStatusType.Start, sessionId, username, }); } /** * Send accounting stop */ public async accountingStop( sessionId: string, options?: { username?: string; sessionTime?: number; inputOctets?: number; outputOctets?: number; terminateCause?: number; } ): Promise { const { EAcctStatusType } = await import('../ts_server/index.js'); return this.accounting({ statusType: EAcctStatusType.Stop, sessionId, ...options, }); } /** * Send accounting interim update */ public async accountingUpdate( sessionId: string, options?: { username?: string; sessionTime?: number; inputOctets?: number; outputOctets?: number; } ): Promise { const { EAcctStatusType } = await import('../ts_server/index.js'); return this.accounting({ statusType: EAcctStatusType.InterimUpdate, sessionId, ...options, }); } private async ensureConnected(): Promise { if (!this.isConnected) { await this.connect(); } } private nextIdentifier(): number { this.currentIdentifier = (this.currentIdentifier + 1) % 256; return this.currentIdentifier; } private sendRequest( identifier: number, packet: Buffer, requestAuthenticator: Buffer, port: number ): Promise { return new Promise((resolve, reject) => { const pending: IPendingRequest = { identifier, requestAuthenticator, resolve, reject, retries: 0, packet, port, }; this.pendingRequests.set(identifier, pending); this.sendWithRetry(pending); }); } private sendWithRetry(pending: IPendingRequest): void { // Send packet this.socket!.send(pending.packet, pending.port, this.options.host, (err) => { if (err) { this.pendingRequests.delete(pending.identifier); pending.reject(err); return; } // Set timeout pending.timeoutId = setTimeout(() => { pending.retries++; if (pending.retries >= this.options.retries) { this.pendingRequests.delete(pending.identifier); pending.reject(new Error(`Request timed out after ${this.options.retries} retries`)); return; } // Exponential backoff const delay = this.options.retryDelay * Math.pow(2, pending.retries - 1); setTimeout(() => { if (this.pendingRequests.has(pending.identifier)) { this.sendWithRetry(pending); } }, delay); }, this.options.timeout); }); } private handleResponse(msg: Buffer): void { if (msg.length < 20) { return; } const identifier = msg.readUInt8(1); const pending = this.pendingRequests.get(identifier); if (!pending) { // Response for unknown request return; } // Clear timeout if (pending.timeoutId) { clearTimeout(pending.timeoutId); } // Remove from pending this.pendingRequests.delete(identifier); // Resolve with response pending.resolve(msg); } private buildAuthResponse(packet: any): IClientAuthResponse { const code = packet.code as ERadiusCode; const accepted = code === ERadiusCode.AccessAccept; const rejected = code === ERadiusCode.AccessReject; const challenged = code === ERadiusCode.AccessChallenge; // Extract common attributes let replyMessage: string | undefined; let sessionTimeout: number | undefined; let idleTimeout: number | undefined; let state: Buffer | undefined; let classAttr: Buffer | undefined; let framedIpAddress: string | undefined; let framedIpNetmask: string | undefined; const framedRoutes: string[] = []; for (const attr of packet.parsedAttributes) { switch (attr.type) { case ERadiusAttributeType.ReplyMessage: replyMessage = attr.value as string; break; case ERadiusAttributeType.SessionTimeout: sessionTimeout = attr.value as number; break; case ERadiusAttributeType.IdleTimeout: idleTimeout = attr.value as number; break; case ERadiusAttributeType.State: state = attr.rawValue; break; case ERadiusAttributeType.Class: classAttr = attr.rawValue; break; case ERadiusAttributeType.FramedIpAddress: framedIpAddress = attr.value as string; break; case ERadiusAttributeType.FramedIpNetmask: framedIpNetmask = attr.value as string; break; case ERadiusAttributeType.FramedRoute: framedRoutes.push(attr.value as string); break; } } return { code, accepted, rejected, challenged, replyMessage, sessionTimeout, idleTimeout, state, class: classAttr, framedIpAddress, framedIpNetmask, framedRoutes: framedRoutes.length > 0 ? framedRoutes : undefined, attributes: packet.parsedAttributes, rawPacket: packet, }; } } export default RadiusClient;