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:
2026-02-01 17:40:36 +00:00
parent 5a6a3cf66e
commit be9f49fff9
45 changed files with 11694 additions and 70 deletions

View File

@@ -0,0 +1,426 @@
import * as plugins from './plugins.js';
import type { IRadiusPacket, IParsedRadiusPacket, IRadiusAttribute, IParsedAttribute } from './interfaces.js';
import { ERadiusCode, ERadiusAttributeType } from './interfaces.js';
import { RadiusAttributes } from './classes.radiusattributes.js';
import { RadiusAuthenticator } from './classes.radiusauthenticator.js';
/**
* RADIUS Packet encoder/decoder
* Implements RFC 2865 Section 3 packet format
*/
export class RadiusPacket {
/**
* Minimum packet size (RFC 2865: 20 bytes)
*/
public static readonly MIN_PACKET_SIZE = 20;
/**
* Maximum packet size (RFC 2865: 4096 bytes)
*/
public static readonly MAX_PACKET_SIZE = 4096;
/**
* Header size (Code + Identifier + Length + Authenticator)
*/
public static readonly HEADER_SIZE = 20;
/**
* Authenticator size
*/
public static readonly AUTHENTICATOR_SIZE = 16;
/**
* Decode a RADIUS packet from a buffer
*/
public static decode(buffer: Buffer): IRadiusPacket {
if (buffer.length < this.MIN_PACKET_SIZE) {
throw new Error(`Packet too short: ${buffer.length} bytes (minimum ${this.MIN_PACKET_SIZE})`);
}
const code = buffer.readUInt8(0);
const identifier = buffer.readUInt8(1);
const length = buffer.readUInt16BE(2);
// Validate code
if (!this.isValidCode(code)) {
throw new Error(`Invalid packet code: ${code}`);
}
// Validate length
if (length < this.MIN_PACKET_SIZE) {
throw new Error(`Invalid packet length in header: ${length}`);
}
if (length > this.MAX_PACKET_SIZE) {
throw new Error(`Packet too large: ${length} bytes (maximum ${this.MAX_PACKET_SIZE})`);
}
if (buffer.length < length) {
throw new Error(`Buffer too short for declared length: ${buffer.length} < ${length}`);
}
const authenticator = Buffer.allocUnsafe(this.AUTHENTICATOR_SIZE);
buffer.copy(authenticator, 0, 4, 20);
const attributes = this.decodeAttributes(buffer.subarray(20, length));
return {
code,
identifier,
authenticator,
attributes,
};
}
/**
* Decode packet and parse attributes
*/
public static decodeAndParse(buffer: Buffer): IParsedRadiusPacket {
const packet = this.decode(buffer);
const parsedAttributes = packet.attributes.map((attr) => RadiusAttributes.parseAttribute(attr));
return {
...packet,
parsedAttributes,
};
}
/**
* Decode attributes from buffer
*/
private static decodeAttributes(buffer: Buffer): IRadiusAttribute[] {
const attributes: IRadiusAttribute[] = [];
let offset = 0;
while (offset < buffer.length) {
if (offset + 2 > buffer.length) {
throw new Error('Malformed attribute: truncated header');
}
const type = buffer.readUInt8(offset);
const length = buffer.readUInt8(offset + 1);
if (length < 2) {
throw new Error(`Invalid attribute length: ${length}`);
}
if (offset + length > buffer.length) {
throw new Error('Malformed attribute: truncated value');
}
const value = Buffer.allocUnsafe(length - 2);
buffer.copy(value, 0, offset + 2, offset + length);
attributes.push({ type, value });
offset += length;
}
return attributes;
}
/**
* Encode a RADIUS packet to a buffer
*/
public static encode(packet: IRadiusPacket): Buffer {
const attributesBuffer = this.encodeAttributes(packet.attributes);
const length = this.HEADER_SIZE + attributesBuffer.length;
if (length > this.MAX_PACKET_SIZE) {
throw new Error(`Packet too large: ${length} bytes (maximum ${this.MAX_PACKET_SIZE})`);
}
const buffer = Buffer.allocUnsafe(length);
buffer.writeUInt8(packet.code, 0);
buffer.writeUInt8(packet.identifier, 1);
buffer.writeUInt16BE(length, 2);
packet.authenticator.copy(buffer, 4);
attributesBuffer.copy(buffer, 20);
return buffer;
}
/**
* Encode attributes to buffer
*/
private static encodeAttributes(attributes: IRadiusAttribute[]): Buffer {
const buffers = attributes.map((attr) => {
const length = 2 + attr.value.length;
if (length > 255) {
throw new Error(`Attribute value too long: ${attr.value.length} bytes (max 253)`);
}
const buffer = Buffer.allocUnsafe(length);
buffer.writeUInt8(attr.type, 0);
buffer.writeUInt8(length, 1);
attr.value.copy(buffer, 2);
return buffer;
});
return Buffer.concat(buffers);
}
/**
* Create an Access-Request packet
*/
public static createAccessRequest(
identifier: number,
secret: string,
attributes: Array<{ type: number | string; value: string | number | Buffer }>
): Buffer {
const requestAuthenticator = RadiusAuthenticator.generateRequestAuthenticator();
const rawAttributes: IRadiusAttribute[] = [];
for (const attr of attributes) {
const attrType = typeof attr.type === 'string'
? RadiusAttributes.getTypeByName(attr.type)
: attr.type;
if (attrType === undefined) {
throw new Error(`Unknown attribute type: ${attr.type}`);
}
let value: Buffer;
if (attrType === ERadiusAttributeType.UserPassword && typeof attr.value === 'string') {
// Encrypt password
value = RadiusAuthenticator.encryptPassword(attr.value, requestAuthenticator, secret);
} else {
value = RadiusAttributes.encodeValue(attrType, attr.value);
}
rawAttributes.push({ type: attrType, value });
}
const packet: IRadiusPacket = {
code: ERadiusCode.AccessRequest,
identifier,
authenticator: requestAuthenticator,
attributes: rawAttributes,
};
return this.encode(packet);
}
/**
* Create an Access-Accept packet
*/
public static createAccessAccept(
identifier: number,
requestAuthenticator: Buffer,
secret: string,
attributes: Array<{ type: number | string; value: string | number | Buffer }> = []
): Buffer {
return this.createResponse(
ERadiusCode.AccessAccept,
identifier,
requestAuthenticator,
secret,
attributes
);
}
/**
* Create an Access-Reject packet
*/
public static createAccessReject(
identifier: number,
requestAuthenticator: Buffer,
secret: string,
attributes: Array<{ type: number | string; value: string | number | Buffer }> = []
): Buffer {
return this.createResponse(
ERadiusCode.AccessReject,
identifier,
requestAuthenticator,
secret,
attributes
);
}
/**
* Create an Access-Challenge packet
*/
public static createAccessChallenge(
identifier: number,
requestAuthenticator: Buffer,
secret: string,
attributes: Array<{ type: number | string; value: string | number | Buffer }> = []
): Buffer {
return this.createResponse(
ERadiusCode.AccessChallenge,
identifier,
requestAuthenticator,
secret,
attributes
);
}
/**
* Create an Accounting-Request packet
*/
public static createAccountingRequest(
identifier: number,
secret: string,
attributes: Array<{ type: number | string; value: string | number | Buffer }>
): Buffer {
const rawAttributes: IRadiusAttribute[] = [];
for (const attr of attributes) {
const attrType = typeof attr.type === 'string'
? RadiusAttributes.getTypeByName(attr.type)
: attr.type;
if (attrType === undefined) {
throw new Error(`Unknown attribute type: ${attr.type}`);
}
const value = RadiusAttributes.encodeValue(attrType, attr.value);
rawAttributes.push({ type: attrType, value });
}
// Calculate authenticator with zero placeholder
const attributesBuffer = this.encodeAttributes(rawAttributes);
const authenticator = RadiusAuthenticator.calculateAccountingRequestAuthenticator(
ERadiusCode.AccountingRequest,
identifier,
attributesBuffer,
secret
);
const packet: IRadiusPacket = {
code: ERadiusCode.AccountingRequest,
identifier,
authenticator,
attributes: rawAttributes,
};
return this.encode(packet);
}
/**
* Create an Accounting-Response packet
*/
public static createAccountingResponse(
identifier: number,
requestAuthenticator: Buffer,
secret: string,
attributes: Array<{ type: number | string; value: string | number | Buffer }> = []
): Buffer {
return this.createResponse(
ERadiusCode.AccountingResponse,
identifier,
requestAuthenticator,
secret,
attributes
);
}
/**
* Create a response packet (Accept, Reject, Challenge, Accounting-Response)
*/
private static createResponse(
code: ERadiusCode,
identifier: number,
requestAuthenticator: Buffer,
secret: string,
attributes: Array<{ type: number | string; value: string | number | Buffer }>
): Buffer {
const rawAttributes: IRadiusAttribute[] = [];
for (const attr of attributes) {
const attrType = typeof attr.type === 'string'
? RadiusAttributes.getTypeByName(attr.type)
: attr.type;
if (attrType === undefined) {
throw new Error(`Unknown attribute type: ${attr.type}`);
}
const value = RadiusAttributes.encodeValue(attrType, attr.value);
rawAttributes.push({ type: attrType, value });
}
const attributesBuffer = this.encodeAttributes(rawAttributes);
// Calculate response authenticator
const responseAuthenticator = RadiusAuthenticator.calculateResponseAuthenticator(
code,
identifier,
requestAuthenticator,
attributesBuffer,
secret
);
const packet: IRadiusPacket = {
code,
identifier,
authenticator: responseAuthenticator,
attributes: rawAttributes,
};
return this.encode(packet);
}
/**
* Get attribute value from a packet
*/
public static getAttribute(packet: IRadiusPacket, type: number | string): Buffer | undefined {
const attrType = typeof type === 'string' ? RadiusAttributes.getTypeByName(type) : type;
if (attrType === undefined) {
return undefined;
}
const attr = packet.attributes.find((a) => a.type === attrType);
return attr?.value;
}
/**
* Get all attribute values from a packet
*/
public static getAttributes(packet: IRadiusPacket, type: number | string): Buffer[] {
const attrType = typeof type === 'string' ? RadiusAttributes.getTypeByName(type) : type;
if (attrType === undefined) {
return [];
}
return packet.attributes.filter((a) => a.type === attrType).map((a) => a.value);
}
/**
* Get parsed attribute value
*/
public static getParsedAttribute(packet: IRadiusPacket, type: number | string): string | number | Buffer | undefined {
const value = this.getAttribute(packet, type);
if (value === undefined) {
return undefined;
}
const attrType = typeof type === 'string' ? RadiusAttributes.getTypeByName(type)! : type;
return RadiusAttributes.parseValue(attrType, value);
}
/**
* Check if a packet code is valid
*/
private static isValidCode(code: number): boolean {
return code === ERadiusCode.AccessRequest ||
code === ERadiusCode.AccessAccept ||
code === ERadiusCode.AccessReject ||
code === ERadiusCode.AccountingRequest ||
code === ERadiusCode.AccountingResponse ||
code === ERadiusCode.AccessChallenge ||
code === ERadiusCode.StatusServer ||
code === ERadiusCode.StatusClient;
}
/**
* Get code name
*/
public static getCodeName(code: ERadiusCode): string {
const names: Record<number, string> = {
[ERadiusCode.AccessRequest]: 'Access-Request',
[ERadiusCode.AccessAccept]: 'Access-Accept',
[ERadiusCode.AccessReject]: 'Access-Reject',
[ERadiusCode.AccountingRequest]: 'Accounting-Request',
[ERadiusCode.AccountingResponse]: 'Accounting-Response',
[ERadiusCode.AccessChallenge]: 'Access-Challenge',
[ERadiusCode.StatusServer]: 'Status-Server',
[ERadiusCode.StatusClient]: 'Status-Client',
};
return names[code] || `Unknown-Code-${code}`;
}
}
export default RadiusPacket;