feat(smartradius): Implement full RADIUS server and client with RFC 2865/2866 compliance, including packet handling, authenticators, attributes, secrets manager, client APIs, and comprehensive tests and documentation
This commit is contained in:
531
ts_client/classes.radiusclient.ts
Normal file
531
ts_client/classes.radiusclient.ts
Normal file
@@ -0,0 +1,531 @@
|
||||
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<typeof setTimeout>;
|
||||
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<IRadiusClientOptions>;
|
||||
private currentIdentifier = 0;
|
||||
private readonly pendingRequests: Map<number, IPendingRequest> = 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<void> {
|
||||
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<void> {
|
||||
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<IClientAuthResponse> {
|
||||
return this.authenticate({
|
||||
username,
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a user using CHAP
|
||||
*/
|
||||
public async authenticateChap(
|
||||
username: string,
|
||||
password: string,
|
||||
challenge?: Buffer
|
||||
): Promise<IClientAuthResponse> {
|
||||
// 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<IClientAuthResponse> {
|
||||
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<IClientAccountingResponse> {
|
||||
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<IClientAccountingResponse> {
|
||||
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<IClientAccountingResponse> {
|
||||
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<IClientAccountingResponse> {
|
||||
const { EAcctStatusType } = await import('../ts_server/index.js');
|
||||
return this.accounting({
|
||||
statusType: EAcctStatusType.InterimUpdate,
|
||||
sessionId,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<void> {
|
||||
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<Buffer> {
|
||||
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;
|
||||
Reference in New Issue
Block a user