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