Files
smartradius/ts_client/classes.radiusclient.ts

532 lines
16 KiB
TypeScript
Raw Permalink Normal View History

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;