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,303 @@
import * as plugins from './plugins.js';
import type {
IAttributeDefinition,
IRadiusAttribute,
IParsedAttribute,
IVendorSpecificAttribute,
TAttributeValueType,
} from './interfaces.js';
import { ERadiusAttributeType } from './interfaces.js';
/**
* RADIUS Attribute Dictionary
* Based on RFC 2865 and RFC 2866
*/
export class RadiusAttributes {
/**
* Standard RADIUS attribute definitions
*/
private static readonly attributeDefinitions: Map<number, IAttributeDefinition> = new Map([
// RFC 2865 Authentication Attributes
[ERadiusAttributeType.UserName, { type: 1, name: 'User-Name', valueType: 'text' }],
[ERadiusAttributeType.UserPassword, { type: 2, name: 'User-Password', valueType: 'string', encrypted: true }],
[ERadiusAttributeType.ChapPassword, { type: 3, name: 'CHAP-Password', valueType: 'string' }],
[ERadiusAttributeType.NasIpAddress, { type: 4, name: 'NAS-IP-Address', valueType: 'address' }],
[ERadiusAttributeType.NasPort, { type: 5, name: 'NAS-Port', valueType: 'integer' }],
[ERadiusAttributeType.ServiceType, { type: 6, name: 'Service-Type', valueType: 'integer' }],
[ERadiusAttributeType.FramedProtocol, { type: 7, name: 'Framed-Protocol', valueType: 'integer' }],
[ERadiusAttributeType.FramedIpAddress, { type: 8, name: 'Framed-IP-Address', valueType: 'address' }],
[ERadiusAttributeType.FramedIpNetmask, { type: 9, name: 'Framed-IP-Netmask', valueType: 'address' }],
[ERadiusAttributeType.FramedRouting, { type: 10, name: 'Framed-Routing', valueType: 'integer' }],
[ERadiusAttributeType.FilterId, { type: 11, name: 'Filter-Id', valueType: 'text' }],
[ERadiusAttributeType.FramedMtu, { type: 12, name: 'Framed-MTU', valueType: 'integer' }],
[ERadiusAttributeType.FramedCompression, { type: 13, name: 'Framed-Compression', valueType: 'integer' }],
[ERadiusAttributeType.LoginIpHost, { type: 14, name: 'Login-IP-Host', valueType: 'address' }],
[ERadiusAttributeType.LoginService, { type: 15, name: 'Login-Service', valueType: 'integer' }],
[ERadiusAttributeType.LoginTcpPort, { type: 16, name: 'Login-TCP-Port', valueType: 'integer' }],
[ERadiusAttributeType.ReplyMessage, { type: 18, name: 'Reply-Message', valueType: 'text' }],
[ERadiusAttributeType.CallbackNumber, { type: 19, name: 'Callback-Number', valueType: 'text' }],
[ERadiusAttributeType.CallbackId, { type: 20, name: 'Callback-Id', valueType: 'text' }],
[ERadiusAttributeType.FramedRoute, { type: 22, name: 'Framed-Route', valueType: 'text' }],
[ERadiusAttributeType.FramedIpxNetwork, { type: 23, name: 'Framed-IPX-Network', valueType: 'integer' }],
[ERadiusAttributeType.State, { type: 24, name: 'State', valueType: 'string' }],
[ERadiusAttributeType.Class, { type: 25, name: 'Class', valueType: 'string' }],
[ERadiusAttributeType.VendorSpecific, { type: 26, name: 'Vendor-Specific', valueType: 'vsa' }],
[ERadiusAttributeType.SessionTimeout, { type: 27, name: 'Session-Timeout', valueType: 'integer' }],
[ERadiusAttributeType.IdleTimeout, { type: 28, name: 'Idle-Timeout', valueType: 'integer' }],
[ERadiusAttributeType.TerminationAction, { type: 29, name: 'Termination-Action', valueType: 'integer' }],
[ERadiusAttributeType.CalledStationId, { type: 30, name: 'Called-Station-Id', valueType: 'text' }],
[ERadiusAttributeType.CallingStationId, { type: 31, name: 'Calling-Station-Id', valueType: 'text' }],
[ERadiusAttributeType.NasIdentifier, { type: 32, name: 'NAS-Identifier', valueType: 'text' }],
[ERadiusAttributeType.ProxyState, { type: 33, name: 'Proxy-State', valueType: 'string' }],
[ERadiusAttributeType.LoginLatService, { type: 34, name: 'Login-LAT-Service', valueType: 'text' }],
[ERadiusAttributeType.LoginLatNode, { type: 35, name: 'Login-LAT-Node', valueType: 'text' }],
[ERadiusAttributeType.LoginLatGroup, { type: 36, name: 'Login-LAT-Group', valueType: 'string' }],
[ERadiusAttributeType.FramedAppleTalkLink, { type: 37, name: 'Framed-AppleTalk-Link', valueType: 'integer' }],
[ERadiusAttributeType.FramedAppleTalkNetwork, { type: 38, name: 'Framed-AppleTalk-Network', valueType: 'integer' }],
[ERadiusAttributeType.FramedAppleTalkZone, { type: 39, name: 'Framed-AppleTalk-Zone', valueType: 'text' }],
[ERadiusAttributeType.ChapChallenge, { type: 60, name: 'CHAP-Challenge', valueType: 'string' }],
[ERadiusAttributeType.NasPortType, { type: 61, name: 'NAS-Port-Type', valueType: 'integer' }],
[ERadiusAttributeType.PortLimit, { type: 62, name: 'Port-Limit', valueType: 'integer' }],
[ERadiusAttributeType.LoginLatPort, { type: 63, name: 'Login-LAT-Port', valueType: 'text' }],
// RFC 2866 Accounting Attributes
[ERadiusAttributeType.AcctStatusType, { type: 40, name: 'Acct-Status-Type', valueType: 'integer' }],
[ERadiusAttributeType.AcctDelayTime, { type: 41, name: 'Acct-Delay-Time', valueType: 'integer' }],
[ERadiusAttributeType.AcctInputOctets, { type: 42, name: 'Acct-Input-Octets', valueType: 'integer' }],
[ERadiusAttributeType.AcctOutputOctets, { type: 43, name: 'Acct-Output-Octets', valueType: 'integer' }],
[ERadiusAttributeType.AcctSessionId, { type: 44, name: 'Acct-Session-Id', valueType: 'text' }],
[ERadiusAttributeType.AcctAuthentic, { type: 45, name: 'Acct-Authentic', valueType: 'integer' }],
[ERadiusAttributeType.AcctSessionTime, { type: 46, name: 'Acct-Session-Time', valueType: 'integer' }],
[ERadiusAttributeType.AcctInputPackets, { type: 47, name: 'Acct-Input-Packets', valueType: 'integer' }],
[ERadiusAttributeType.AcctOutputPackets, { type: 48, name: 'Acct-Output-Packets', valueType: 'integer' }],
[ERadiusAttributeType.AcctTerminateCause, { type: 49, name: 'Acct-Terminate-Cause', valueType: 'integer' }],
[ERadiusAttributeType.AcctMultiSessionId, { type: 50, name: 'Acct-Multi-Session-Id', valueType: 'text' }],
[ERadiusAttributeType.AcctLinkCount, { type: 51, name: 'Acct-Link-Count', valueType: 'integer' }],
// EAP support
[ERadiusAttributeType.EapMessage, { type: 79, name: 'EAP-Message', valueType: 'string' }],
[ERadiusAttributeType.MessageAuthenticator, { type: 80, name: 'Message-Authenticator', valueType: 'string' }],
]);
/**
* Attribute name to type mapping
*/
private static readonly nameToType: Map<string, number> = new Map(
Array.from(RadiusAttributes.attributeDefinitions.entries()).map(([type, def]) => [def.name, type])
);
/**
* Get attribute definition by type
*/
public static getDefinition(type: number): IAttributeDefinition | undefined {
return this.attributeDefinitions.get(type);
}
/**
* Get attribute type by name
*/
public static getTypeByName(name: string): number | undefined {
return this.nameToType.get(name);
}
/**
* Get attribute name by type
*/
public static getNameByType(type: number): string {
const def = this.attributeDefinitions.get(type);
return def ? def.name : `Unknown-Attribute-${type}`;
}
/**
* Parse attribute value based on its type
*/
public static parseValue(type: number, value: Buffer): string | number | Buffer {
const def = this.attributeDefinitions.get(type);
if (!def) {
return value;
}
switch (def.valueType) {
case 'text':
return value.toString('utf8');
case 'address':
return this.parseAddress(value);
case 'integer':
return this.parseInteger(value);
case 'time':
return this.parseInteger(value);
case 'string':
case 'vsa':
default:
return value;
}
}
/**
* Encode attribute value
*/
public static encodeValue(type: number | string, value: string | number | Buffer): Buffer {
const attrType = typeof type === 'string' ? this.getTypeByName(type) : type;
if (attrType === undefined) {
throw new Error(`Unknown attribute type: ${type}`);
}
const def = this.attributeDefinitions.get(attrType);
if (!def) {
if (Buffer.isBuffer(value)) {
return value;
}
return Buffer.from(String(value), 'utf8');
}
switch (def.valueType) {
case 'text':
return Buffer.from(String(value), 'utf8');
case 'address':
return this.encodeAddress(String(value));
case 'integer':
case 'time':
return this.encodeInteger(Number(value));
case 'string':
case 'vsa':
default:
if (Buffer.isBuffer(value)) {
return value;
}
return Buffer.from(String(value), 'utf8');
}
}
/**
* Parse IPv4 address from buffer (4 bytes)
*/
private static parseAddress(buffer: Buffer): string {
if (buffer.length < 4) {
return '0.0.0.0';
}
return `${buffer[0]}.${buffer[1]}.${buffer[2]}.${buffer[3]}`;
}
/**
* Encode IPv4 address to buffer
*/
private static encodeAddress(address: string): Buffer {
const parts = address.split('.').map((p) => parseInt(p, 10));
if (parts.length !== 4 || parts.some((p) => isNaN(p) || p < 0 || p > 255)) {
throw new Error(`Invalid IP address: ${address}`);
}
return Buffer.from(parts);
}
/**
* Parse 32-bit unsigned integer from buffer (big-endian)
*/
private static parseInteger(buffer: Buffer): number {
if (buffer.length < 4) {
return 0;
}
return buffer.readUInt32BE(0);
}
/**
* Encode 32-bit unsigned integer to buffer (big-endian)
*/
private static encodeInteger(value: number): Buffer {
const buffer = Buffer.allocUnsafe(4);
buffer.writeUInt32BE(value >>> 0, 0);
return buffer;
}
/**
* Encode a complete attribute (type + length + value)
*/
public static encodeAttribute(type: number | string, value: string | number | Buffer): Buffer {
const attrType = typeof type === 'string' ? this.getTypeByName(type) : type;
if (attrType === undefined) {
throw new Error(`Unknown attribute type: ${type}`);
}
const encodedValue = this.encodeValue(attrType, value);
const length = 2 + encodedValue.length;
if (length > 255) {
throw new Error(`Attribute value too long: ${length} bytes (max 253 value bytes)`);
}
const buffer = Buffer.allocUnsafe(length);
buffer.writeUInt8(attrType, 0);
buffer.writeUInt8(length, 1);
encodedValue.copy(buffer, 2);
return buffer;
}
/**
* Parse raw attribute into named attribute
*/
public static parseAttribute(attr: IRadiusAttribute): IParsedAttribute {
return {
type: attr.type,
name: this.getNameByType(attr.type),
value: this.parseValue(attr.type, attr.value),
rawValue: attr.value,
};
}
/**
* Parse Vendor-Specific Attribute (RFC 2865 Section 5.26)
* Format: Vendor-Id (4 bytes) + Vendor-Type (1 byte) + Vendor-Length (1 byte) + Value
*/
public static parseVSA(buffer: Buffer): IVendorSpecificAttribute | null {
if (buffer.length < 6) {
return null;
}
const vendorId = buffer.readUInt32BE(0);
const vendorType = buffer.readUInt8(4);
const vendorLength = buffer.readUInt8(5);
if (buffer.length < 4 + vendorLength) {
return null;
}
const vendorValue = buffer.subarray(6, 4 + vendorLength);
return {
vendorId,
vendorType,
vendorValue,
};
}
/**
* Encode Vendor-Specific Attribute
*/
public static encodeVSA(vsa: IVendorSpecificAttribute): Buffer {
const valueLength = vsa.vendorValue.length;
const vendorLength = 2 + valueLength; // vendor-type + vendor-length + value
const totalLength = 4 + vendorLength; // vendor-id + vendor sub-attributes
const buffer = Buffer.allocUnsafe(totalLength);
buffer.writeUInt32BE(vsa.vendorId, 0);
buffer.writeUInt8(vsa.vendorType, 4);
buffer.writeUInt8(vendorLength, 5);
vsa.vendorValue.copy(buffer, 6);
return buffer;
}
/**
* Create a complete Vendor-Specific attribute (type 26)
*/
public static createVendorAttribute(vendorId: number, vendorType: number, vendorValue: Buffer): Buffer {
const vsaValue = this.encodeVSA({ vendorId, vendorType, vendorValue });
return this.encodeAttribute(ERadiusAttributeType.VendorSpecific, vsaValue);
}
/**
* Check if an attribute is encrypted (e.g., User-Password)
*/
public static isEncrypted(type: number): boolean {
const def = this.attributeDefinitions.get(type);
return def?.encrypted === true;
}
}
export default RadiusAttributes;

View File

@@ -0,0 +1,302 @@
import * as plugins from './plugins.js';
import { ERadiusCode } from './interfaces.js';
/**
* RADIUS Authenticator handling
* Implements RFC 2865 and RFC 2866 authenticator calculations
*/
export class RadiusAuthenticator {
private static readonly AUTHENTICATOR_LENGTH = 16;
/**
* Generate random Request Authenticator for Access-Request
* RFC 2865: The Request Authenticator value is a 16 octet random number
*/
public static generateRequestAuthenticator(): Buffer {
return plugins.crypto.randomBytes(this.AUTHENTICATOR_LENGTH);
}
/**
* Calculate Response Authenticator for Access-Accept, Access-Reject, Access-Challenge
* RFC 2865: ResponseAuth = MD5(Code+ID+Length+RequestAuth+Attributes+Secret)
*/
public static calculateResponseAuthenticator(
code: ERadiusCode,
identifier: number,
requestAuthenticator: Buffer,
attributes: Buffer,
secret: string
): Buffer {
const length = 20 + attributes.length; // 20 = header size
const md5 = plugins.crypto.createHash('md5');
const header = Buffer.allocUnsafe(4);
header.writeUInt8(code, 0);
header.writeUInt8(identifier, 1);
header.writeUInt16BE(length, 2);
md5.update(header);
md5.update(requestAuthenticator);
md5.update(attributes);
md5.update(Buffer.from(secret, 'utf8'));
return md5.digest();
}
/**
* Calculate Accounting Request Authenticator
* RFC 2866: MD5(Code+ID+Length+16×0x00+Attrs+Secret)
*/
public static calculateAccountingRequestAuthenticator(
code: ERadiusCode,
identifier: number,
attributes: Buffer,
secret: string
): Buffer {
const length = 20 + attributes.length;
const zeroAuth = Buffer.alloc(this.AUTHENTICATOR_LENGTH, 0);
const md5 = plugins.crypto.createHash('md5');
const header = Buffer.allocUnsafe(4);
header.writeUInt8(code, 0);
header.writeUInt8(identifier, 1);
header.writeUInt16BE(length, 2);
md5.update(header);
md5.update(zeroAuth);
md5.update(attributes);
md5.update(Buffer.from(secret, 'utf8'));
return md5.digest();
}
/**
* Verify Accounting Request Authenticator
*/
public static verifyAccountingRequestAuthenticator(
packet: Buffer,
secret: string
): boolean {
if (packet.length < 20) {
return false;
}
const code = packet.readUInt8(0);
const identifier = packet.readUInt8(1);
const length = packet.readUInt16BE(2);
const receivedAuth = packet.subarray(4, 20);
const attributes = packet.subarray(20, length);
const expectedAuth = this.calculateAccountingRequestAuthenticator(
code,
identifier,
attributes,
secret
);
return plugins.crypto.timingSafeEqual(receivedAuth, expectedAuth);
}
/**
* Verify Response Authenticator
*/
public static verifyResponseAuthenticator(
responsePacket: Buffer,
requestAuthenticator: Buffer,
secret: string
): boolean {
if (responsePacket.length < 20) {
return false;
}
const code = responsePacket.readUInt8(0);
const identifier = responsePacket.readUInt8(1);
const length = responsePacket.readUInt16BE(2);
const receivedAuth = responsePacket.subarray(4, 20);
const attributes = responsePacket.subarray(20, length);
const expectedAuth = this.calculateResponseAuthenticator(
code,
identifier,
requestAuthenticator,
attributes,
secret
);
return plugins.crypto.timingSafeEqual(receivedAuth, expectedAuth);
}
/**
* Encrypt User-Password (PAP)
* RFC 2865 Section 5.2:
* b1 = MD5(S + RA) c(1) = p1 xor b1
* b2 = MD5(S + c(1)) c(2) = p2 xor b2
* ...
* bi = MD5(S + c(i-1)) c(i) = pi xor bi
*/
public static encryptPassword(
password: string,
requestAuthenticator: Buffer,
secret: string
): Buffer {
const passwordBuffer = Buffer.from(password, 'utf8');
// Pad password to multiple of 16 bytes
const paddedLength = Math.max(16, Math.ceil(passwordBuffer.length / 16) * 16);
const padded = Buffer.alloc(paddedLength, 0);
passwordBuffer.copy(padded);
// Limit to 128 bytes max
const maxLength = Math.min(paddedLength, 128);
const result = Buffer.allocUnsafe(maxLength);
let previousCipher = requestAuthenticator;
for (let i = 0; i < maxLength; i += 16) {
const md5 = plugins.crypto.createHash('md5');
md5.update(Buffer.from(secret, 'utf8'));
md5.update(previousCipher);
const b = md5.digest();
for (let j = 0; j < 16 && i + j < maxLength; j++) {
result[i + j] = padded[i + j] ^ b[j];
}
previousCipher = result.subarray(i, i + 16);
}
return result;
}
/**
* Decrypt User-Password (PAP)
* Reverse of encryptPassword
*/
public static decryptPassword(
encryptedPassword: Buffer,
requestAuthenticator: Buffer,
secret: string
): string {
if (encryptedPassword.length === 0 || encryptedPassword.length % 16 !== 0) {
throw new Error('Invalid encrypted password length');
}
const result = Buffer.allocUnsafe(encryptedPassword.length);
let previousCipher = requestAuthenticator;
for (let i = 0; i < encryptedPassword.length; i += 16) {
const md5 = plugins.crypto.createHash('md5');
md5.update(Buffer.from(secret, 'utf8'));
md5.update(previousCipher);
const b = md5.digest();
for (let j = 0; j < 16; j++) {
result[i + j] = encryptedPassword[i + j] ^ b[j];
}
previousCipher = encryptedPassword.subarray(i, i + 16);
}
// Remove null padding
let end = result.length;
while (end > 0 && result[end - 1] === 0) {
end--;
}
return result.subarray(0, end).toString('utf8');
}
/**
* Calculate CHAP Response
* RFC 2865: CHAP-Response = MD5(CHAP-ID + Password + Challenge)
*/
public static calculateChapResponse(
chapId: number,
password: string,
challenge: Buffer
): Buffer {
const md5 = plugins.crypto.createHash('md5');
md5.update(Buffer.from([chapId]));
md5.update(Buffer.from(password, 'utf8'));
md5.update(challenge);
return md5.digest();
}
/**
* Verify CHAP Response
* CHAP-Password format: CHAP Ident (1 byte) + String (16 bytes MD5 hash)
*/
public static verifyChapResponse(
chapPassword: Buffer,
challenge: Buffer,
password: string
): boolean {
if (chapPassword.length !== 17) {
return false;
}
const chapId = chapPassword[0];
const receivedResponse = chapPassword.subarray(1);
const expectedResponse = this.calculateChapResponse(chapId, password, challenge);
return plugins.crypto.timingSafeEqual(receivedResponse, expectedResponse);
}
/**
* Calculate Message-Authenticator (HMAC-MD5)
* Used for EAP and other extended authentication methods
* RFC 3579: HMAC-MD5 over entire packet with Message-Authenticator set to 16 zero bytes
*/
public static calculateMessageAuthenticator(
packet: Buffer,
secret: string
): Buffer {
// The packet should have Message-Authenticator attribute with 16 zero bytes
const hmac = plugins.crypto.createHmac('md5', Buffer.from(secret, 'utf8'));
hmac.update(packet);
return hmac.digest();
}
/**
* Verify Message-Authenticator
*/
public static verifyMessageAuthenticator(
packet: Buffer,
messageAuthenticator: Buffer,
messageAuthenticatorOffset: number,
secret: string
): boolean {
if (messageAuthenticator.length !== 16) {
return false;
}
// Create a copy of the packet with Message-Authenticator set to 16 zero bytes
const packetCopy = Buffer.from(packet);
Buffer.alloc(16, 0).copy(packetCopy, messageAuthenticatorOffset);
const expectedAuth = this.calculateMessageAuthenticator(packetCopy, secret);
return plugins.crypto.timingSafeEqual(messageAuthenticator, expectedAuth);
}
/**
* Create packet header
*/
public static createPacketHeader(
code: ERadiusCode,
identifier: number,
authenticator: Buffer,
attributesLength: number
): Buffer {
const header = Buffer.allocUnsafe(20);
const length = 20 + attributesLength;
header.writeUInt8(code, 0);
header.writeUInt8(identifier, 1);
header.writeUInt16BE(length, 2);
authenticator.copy(header, 4);
return header;
}
}
export default RadiusAuthenticator;

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;

View File

@@ -0,0 +1,116 @@
import type { TSecretResolver } from './interfaces.js';
/**
* RADIUS Shared Secrets Manager
* Manages per-client shared secrets for RADIUS authentication
*/
export class RadiusSecrets {
private readonly secrets: Map<string, string> = new Map();
private defaultSecret?: string;
private customResolver?: TSecretResolver;
/**
* Create a new secrets manager
*/
constructor(options?: {
defaultSecret?: string;
secrets?: Record<string, string>;
resolver?: TSecretResolver;
}) {
if (options?.defaultSecret) {
this.defaultSecret = options.defaultSecret;
}
if (options?.secrets) {
for (const [ip, secret] of Object.entries(options.secrets)) {
this.secrets.set(ip, secret);
}
}
if (options?.resolver) {
this.customResolver = options.resolver;
}
}
/**
* Set secret for a specific client IP
*/
public setClientSecret(clientIp: string, secret: string): void {
this.secrets.set(clientIp, secret);
}
/**
* Remove secret for a specific client IP
*/
public removeClientSecret(clientIp: string): boolean {
return this.secrets.delete(clientIp);
}
/**
* Set the default secret
*/
public setDefaultSecret(secret: string): void {
this.defaultSecret = secret;
}
/**
* Set a custom resolver
*/
public setResolver(resolver: TSecretResolver): void {
this.customResolver = resolver;
}
/**
* Get secret for a client IP
* Priority: 1. Custom resolver, 2. Per-client secret, 3. Default secret
*/
public getSecret(clientIp: string): string | undefined {
// Try custom resolver first
if (this.customResolver) {
const resolved = this.customResolver(clientIp);
if (resolved !== undefined) {
return resolved;
}
}
// Try per-client secret
const clientSecret = this.secrets.get(clientIp);
if (clientSecret !== undefined) {
return clientSecret;
}
// Fall back to default secret
return this.defaultSecret;
}
/**
* Check if a client is known (has a secret)
*/
public isKnownClient(clientIp: string): boolean {
return this.getSecret(clientIp) !== undefined;
}
/**
* Get all registered client IPs
*/
public getClientIps(): string[] {
return Array.from(this.secrets.keys());
}
/**
* Clear all per-client secrets
*/
public clearClientSecrets(): void {
this.secrets.clear();
}
/**
* Set secrets from a CIDR range (simplified - just IP addresses)
* For actual CIDR support, use a custom resolver
*/
public setSecretsForNetwork(network: string, secret: string): void {
// For simplicity, this just sets a single IP
// Real CIDR support would require a resolver
this.secrets.set(network, secret);
}
}
export default RadiusSecrets;

View File

@@ -0,0 +1,649 @@
import * as plugins from './plugins.js';
import type {
IRadiusServerOptions,
IRadiusServerStats,
IRadiusPacket,
IAuthenticationRequest,
IAuthenticationResponse,
IAccountingRequest,
IAccountingResponse,
TAuthenticationHandler,
TAccountingHandler,
} from './interfaces.js';
import {
ERadiusCode,
ERadiusAttributeType,
EAcctStatusType,
ENasPortType,
EServiceType,
} from './interfaces.js';
import { RadiusPacket } from './classes.radiuspacket.js';
import { RadiusAttributes } from './classes.radiusattributes.js';
import { RadiusAuthenticator } from './classes.radiusauthenticator.js';
import { RadiusSecrets } from './classes.radiussecrets.js';
/**
* RADIUS Server implementation
* Implements RFC 2865 (Authentication) and RFC 2866 (Accounting)
*/
export class RadiusServer {
private authSocket?: plugins.dgram.Socket;
private acctSocket?: plugins.dgram.Socket;
private readonly secrets: RadiusSecrets;
private authenticationHandler?: TAuthenticationHandler;
private accountingHandler?: TAccountingHandler;
private readonly options: Required<
Pick<IRadiusServerOptions, 'authPort' | 'acctPort' | 'bindAddress' | 'duplicateDetectionWindow' | 'maxPacketSize'>
>;
private readonly stats: IRadiusServerStats = {
authRequests: 0,
authAccepts: 0,
authRejects: 0,
authChallenges: 0,
authInvalidPackets: 0,
authUnknownClients: 0,
acctRequests: 0,
acctResponses: 0,
acctInvalidPackets: 0,
acctUnknownClients: 0,
};
// Duplicate detection cache: key = clientIp:clientPort:identifier
private readonly recentRequests: Map<string, { timestamp: number; response?: Buffer }> = new Map();
private duplicateCleanupInterval?: ReturnType<typeof setInterval>;
constructor(options: IRadiusServerOptions = {}) {
this.options = {
authPort: options.authPort ?? 1812,
acctPort: options.acctPort ?? 1813,
bindAddress: options.bindAddress ?? '0.0.0.0',
duplicateDetectionWindow: options.duplicateDetectionWindow ?? 10000, // 10 seconds
maxPacketSize: options.maxPacketSize ?? RadiusPacket.MAX_PACKET_SIZE,
};
this.secrets = new RadiusSecrets({
defaultSecret: options.defaultSecret,
resolver: options.secretResolver,
});
if (options.authenticationHandler) {
this.authenticationHandler = options.authenticationHandler;
}
if (options.accountingHandler) {
this.accountingHandler = options.accountingHandler;
}
}
/**
* Start the RADIUS server
*/
public async start(): Promise<void> {
await Promise.all([
this.startAuthServer(),
this.startAcctServer(),
]);
// Start duplicate detection cleanup
this.duplicateCleanupInterval = setInterval(() => {
this.cleanupDuplicateCache();
}, this.options.duplicateDetectionWindow);
}
/**
* Stop the RADIUS server
*/
public async stop(): Promise<void> {
if (this.duplicateCleanupInterval) {
clearInterval(this.duplicateCleanupInterval);
this.duplicateCleanupInterval = undefined;
}
const stopPromises: Promise<void>[] = [];
if (this.authSocket) {
stopPromises.push(new Promise<void>((resolve) => {
this.authSocket!.close(() => {
this.authSocket = undefined;
resolve();
});
}));
}
if (this.acctSocket) {
stopPromises.push(new Promise<void>((resolve) => {
this.acctSocket!.close(() => {
this.acctSocket = undefined;
resolve();
});
}));
}
await Promise.all(stopPromises);
}
/**
* Set the authentication handler
*/
public setAuthenticationHandler(handler: TAuthenticationHandler): void {
this.authenticationHandler = handler;
}
/**
* Set the accounting handler
*/
public setAccountingHandler(handler: TAccountingHandler): void {
this.accountingHandler = handler;
}
/**
* Set secret for a client
*/
public setClientSecret(clientIp: string, secret: string): void {
this.secrets.setClientSecret(clientIp, secret);
}
/**
* Get server statistics
*/
public getStats(): IRadiusServerStats {
return { ...this.stats };
}
/**
* Reset server statistics
*/
public resetStats(): void {
this.stats.authRequests = 0;
this.stats.authAccepts = 0;
this.stats.authRejects = 0;
this.stats.authChallenges = 0;
this.stats.authInvalidPackets = 0;
this.stats.authUnknownClients = 0;
this.stats.acctRequests = 0;
this.stats.acctResponses = 0;
this.stats.acctInvalidPackets = 0;
this.stats.acctUnknownClients = 0;
}
private async startAuthServer(): Promise<void> {
return new Promise((resolve, reject) => {
this.authSocket = plugins.dgram.createSocket('udp4');
this.authSocket.on('error', (err) => {
reject(err);
});
this.authSocket.on('message', (msg, rinfo) => {
this.handleAuthMessage(msg, rinfo).catch((err) => {
console.error('Error handling auth message:', err);
});
});
this.authSocket.bind(this.options.authPort, this.options.bindAddress, () => {
resolve();
});
});
}
private async startAcctServer(): Promise<void> {
return new Promise((resolve, reject) => {
this.acctSocket = plugins.dgram.createSocket('udp4');
this.acctSocket.on('error', (err) => {
reject(err);
});
this.acctSocket.on('message', (msg, rinfo) => {
this.handleAcctMessage(msg, rinfo).catch((err) => {
console.error('Error handling acct message:', err);
});
});
this.acctSocket.bind(this.options.acctPort, this.options.bindAddress, () => {
resolve();
});
});
}
private async handleAuthMessage(msg: Buffer, rinfo: plugins.dgram.RemoteInfo): Promise<void> {
// Validate packet size
if (msg.length > this.options.maxPacketSize) {
this.stats.authInvalidPackets++;
return;
}
// Get client secret
const secret = this.secrets.getSecret(rinfo.address);
if (!secret) {
this.stats.authUnknownClients++;
return;
}
// Parse packet
let packet: IRadiusPacket;
try {
packet = RadiusPacket.decode(msg);
} catch (err) {
this.stats.authInvalidPackets++;
return;
}
// Only handle Access-Request
if (packet.code !== ERadiusCode.AccessRequest) {
this.stats.authInvalidPackets++;
return;
}
this.stats.authRequests++;
// Check for duplicate
const duplicateKey = `${rinfo.address}:${rinfo.port}:${packet.identifier}`;
const cached = this.recentRequests.get(duplicateKey);
if (cached && cached.response) {
// Retransmit cached response
this.authSocket!.send(cached.response, rinfo.port, rinfo.address);
return;
}
// Mark as in-progress (no response yet)
this.recentRequests.set(duplicateKey, { timestamp: Date.now() });
// Build authentication request context
const authRequest = this.buildAuthRequest(packet, secret, rinfo);
// Call authentication handler
let response: IAuthenticationResponse;
if (this.authenticationHandler) {
try {
response = await this.authenticationHandler(authRequest);
} catch (err) {
response = {
code: ERadiusCode.AccessReject,
replyMessage: 'Internal server error',
};
}
} else {
// No handler configured - reject all
response = {
code: ERadiusCode.AccessReject,
replyMessage: 'Authentication service not configured',
};
}
// Build response packet
const responsePacket = this.buildAuthResponse(
response,
packet.identifier,
packet.authenticator,
secret
);
// Update stats
switch (response.code) {
case ERadiusCode.AccessAccept:
this.stats.authAccepts++;
break;
case ERadiusCode.AccessReject:
this.stats.authRejects++;
break;
case ERadiusCode.AccessChallenge:
this.stats.authChallenges++;
break;
}
// Cache response for duplicate detection
this.recentRequests.set(duplicateKey, {
timestamp: Date.now(),
response: responsePacket,
});
// Send response
this.authSocket!.send(responsePacket, rinfo.port, rinfo.address);
}
private async handleAcctMessage(msg: Buffer, rinfo: plugins.dgram.RemoteInfo): Promise<void> {
// Validate packet size
if (msg.length > this.options.maxPacketSize) {
this.stats.acctInvalidPackets++;
return;
}
// Get client secret
const secret = this.secrets.getSecret(rinfo.address);
if (!secret) {
this.stats.acctUnknownClients++;
return;
}
// Verify accounting request authenticator
if (!RadiusAuthenticator.verifyAccountingRequestAuthenticator(msg, secret)) {
this.stats.acctInvalidPackets++;
return;
}
// Parse packet
let packet: IRadiusPacket;
try {
packet = RadiusPacket.decode(msg);
} catch (err) {
this.stats.acctInvalidPackets++;
return;
}
// Only handle Accounting-Request
if (packet.code !== ERadiusCode.AccountingRequest) {
this.stats.acctInvalidPackets++;
return;
}
this.stats.acctRequests++;
// Check for duplicate
const duplicateKey = `acct:${rinfo.address}:${rinfo.port}:${packet.identifier}`;
const cached = this.recentRequests.get(duplicateKey);
if (cached && cached.response) {
// Retransmit cached response
this.acctSocket!.send(cached.response, rinfo.port, rinfo.address);
return;
}
// Mark as in-progress
this.recentRequests.set(duplicateKey, { timestamp: Date.now() });
// Build accounting request context
const acctRequest = this.buildAcctRequest(packet, rinfo);
// Call accounting handler
let response: IAccountingResponse;
if (this.accountingHandler) {
try {
response = await this.accountingHandler(acctRequest);
} catch (err) {
// Don't respond if we can't record the accounting data
return;
}
} else {
// No handler configured - accept all
response = { success: true };
}
// Only respond if accounting was successful
if (!response.success) {
return;
}
// Build response packet
const responsePacket = RadiusPacket.createAccountingResponse(
packet.identifier,
packet.authenticator,
secret,
response.attributes || []
);
this.stats.acctResponses++;
// Cache response for duplicate detection
this.recentRequests.set(duplicateKey, {
timestamp: Date.now(),
response: responsePacket,
});
// Send response
this.acctSocket!.send(responsePacket, rinfo.port, rinfo.address);
}
private buildAuthRequest(
packet: IRadiusPacket,
secret: string,
rinfo: plugins.dgram.RemoteInfo
): IAuthenticationRequest {
const getUsernameAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.UserName);
const username = getUsernameAttr ? getUsernameAttr.toString('utf8') : '';
// Decrypt PAP password if present
const passwordAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.UserPassword);
let password: string | undefined;
if (passwordAttr) {
try {
password = RadiusAuthenticator.decryptPassword(passwordAttr, packet.authenticator, secret);
} catch {
password = undefined;
}
}
// Get CHAP attributes
const chapPassword = RadiusPacket.getAttribute(packet, ERadiusAttributeType.ChapPassword);
let chapChallenge = RadiusPacket.getAttribute(packet, ERadiusAttributeType.ChapChallenge);
// If no CHAP-Challenge attribute, use Request Authenticator as challenge
if (chapPassword && !chapChallenge) {
chapChallenge = packet.authenticator;
}
// Get NAS attributes
const nasIpAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.NasIpAddress);
const nasIpAddress = nasIpAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.NasIpAddress, nasIpAttr) as string : undefined;
const nasIdAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.NasIdentifier);
const nasIdentifier = nasIdAttr ? nasIdAttr.toString('utf8') : undefined;
const nasPortAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.NasPort);
const nasPort = nasPortAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.NasPort, nasPortAttr) as number : undefined;
const nasPortTypeAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.NasPortType);
const nasPortType = nasPortTypeAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.NasPortType, nasPortTypeAttr) as ENasPortType : undefined;
// Get other common attributes
const calledStationIdAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.CalledStationId);
const calledStationId = calledStationIdAttr ? calledStationIdAttr.toString('utf8') : undefined;
const callingStationIdAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.CallingStationId);
const callingStationId = callingStationIdAttr ? callingStationIdAttr.toString('utf8') : undefined;
const serviceTypeAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.ServiceType);
const serviceType = serviceTypeAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.ServiceType, serviceTypeAttr) as EServiceType : undefined;
const framedProtocolAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.FramedProtocol);
const framedProtocol = framedProtocolAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.FramedProtocol, framedProtocolAttr) as number : undefined;
const stateAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.State);
return {
username,
password,
chapPassword,
chapChallenge,
nasIpAddress,
nasIdentifier,
nasPort,
nasPortType,
calledStationId,
callingStationId,
serviceType,
framedProtocol,
state: stateAttr,
rawPacket: packet,
clientAddress: rinfo.address,
clientPort: rinfo.port,
};
}
private buildAcctRequest(
packet: IRadiusPacket,
rinfo: plugins.dgram.RemoteInfo
): IAccountingRequest {
const statusTypeAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctStatusType);
const statusType = statusTypeAttr
? RadiusAttributes.parseValue(ERadiusAttributeType.AcctStatusType, statusTypeAttr) as EAcctStatusType
: EAcctStatusType.Start;
const sessionIdAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctSessionId);
const sessionId = sessionIdAttr ? sessionIdAttr.toString('utf8') : '';
const usernameAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.UserName);
const username = usernameAttr ? usernameAttr.toString('utf8') : undefined;
const nasIpAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.NasIpAddress);
const nasIpAddress = nasIpAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.NasIpAddress, nasIpAttr) as string : undefined;
const nasIdAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.NasIdentifier);
const nasIdentifier = nasIdAttr ? nasIdAttr.toString('utf8') : undefined;
const nasPortAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.NasPort);
const nasPort = nasPortAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.NasPort, nasPortAttr) as number : undefined;
const nasPortTypeAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.NasPortType);
const nasPortType = nasPortTypeAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.NasPortType, nasPortTypeAttr) as ENasPortType : undefined;
const delayTimeAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctDelayTime);
const delayTime = delayTimeAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.AcctDelayTime, delayTimeAttr) as number : undefined;
const inputOctetsAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctInputOctets);
const inputOctets = inputOctetsAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.AcctInputOctets, inputOctetsAttr) as number : undefined;
const outputOctetsAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctOutputOctets);
const outputOctets = outputOctetsAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.AcctOutputOctets, outputOctetsAttr) as number : undefined;
const sessionTimeAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctSessionTime);
const sessionTime = sessionTimeAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.AcctSessionTime, sessionTimeAttr) as number : undefined;
const inputPacketsAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctInputPackets);
const inputPackets = inputPacketsAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.AcctInputPackets, inputPacketsAttr) as number : undefined;
const outputPacketsAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctOutputPackets);
const outputPackets = outputPacketsAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.AcctOutputPackets, outputPacketsAttr) as number : undefined;
const terminateCauseAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctTerminateCause);
const terminateCause = terminateCauseAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.AcctTerminateCause, terminateCauseAttr) as number : undefined;
const authenticAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctAuthentic);
const authentic = authenticAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.AcctAuthentic, authenticAttr) as number : undefined;
const multiSessionIdAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctMultiSessionId);
const multiSessionId = multiSessionIdAttr ? multiSessionIdAttr.toString('utf8') : undefined;
const linkCountAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.AcctLinkCount);
const linkCount = linkCountAttr ? RadiusAttributes.parseValue(ERadiusAttributeType.AcctLinkCount, linkCountAttr) as number : undefined;
const calledStationIdAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.CalledStationId);
const calledStationId = calledStationIdAttr ? calledStationIdAttr.toString('utf8') : undefined;
const callingStationIdAttr = RadiusPacket.getAttribute(packet, ERadiusAttributeType.CallingStationId);
const callingStationId = callingStationIdAttr ? callingStationIdAttr.toString('utf8') : undefined;
return {
statusType,
sessionId,
username,
nasIpAddress,
nasIdentifier,
nasPort,
nasPortType,
delayTime,
inputOctets,
outputOctets,
sessionTime,
inputPackets,
outputPackets,
terminateCause,
authentic,
multiSessionId,
linkCount,
calledStationId,
callingStationId,
rawPacket: packet,
clientAddress: rinfo.address,
clientPort: rinfo.port,
};
}
private buildAuthResponse(
response: IAuthenticationResponse,
identifier: number,
requestAuthenticator: Buffer,
secret: string
): Buffer {
const attributes: Array<{ type: number | string; value: string | number | Buffer }> = [];
// Add reply message if present
if (response.replyMessage) {
attributes.push({ type: ERadiusAttributeType.ReplyMessage, value: response.replyMessage });
}
// Add session timeout if present
if (response.sessionTimeout !== undefined) {
attributes.push({ type: ERadiusAttributeType.SessionTimeout, value: response.sessionTimeout });
}
// Add idle timeout if present
if (response.idleTimeout !== undefined) {
attributes.push({ type: ERadiusAttributeType.IdleTimeout, value: response.idleTimeout });
}
// Add state if present
if (response.state) {
attributes.push({ type: ERadiusAttributeType.State, value: response.state });
}
// Add class if present
if (response.class) {
attributes.push({ type: ERadiusAttributeType.Class, value: response.class });
}
// Add framed IP if present
if (response.framedIpAddress) {
attributes.push({ type: ERadiusAttributeType.FramedIpAddress, value: response.framedIpAddress });
}
// Add framed IP netmask if present
if (response.framedIpNetmask) {
attributes.push({ type: ERadiusAttributeType.FramedIpNetmask, value: response.framedIpNetmask });
}
// Add framed routes if present
if (response.framedRoutes) {
for (const route of response.framedRoutes) {
attributes.push({ type: ERadiusAttributeType.FramedRoute, value: route });
}
}
// Add vendor attributes if present
if (response.vendorAttributes) {
for (const vsa of response.vendorAttributes) {
const vsaBuffer = RadiusAttributes.encodeVSA(vsa);
attributes.push({ type: ERadiusAttributeType.VendorSpecific, value: vsaBuffer });
}
}
// Add custom attributes if present
if (response.attributes) {
attributes.push(...response.attributes);
}
// Create response based on code
switch (response.code) {
case ERadiusCode.AccessAccept:
return RadiusPacket.createAccessAccept(identifier, requestAuthenticator, secret, attributes);
case ERadiusCode.AccessReject:
return RadiusPacket.createAccessReject(identifier, requestAuthenticator, secret, attributes);
case ERadiusCode.AccessChallenge:
return RadiusPacket.createAccessChallenge(identifier, requestAuthenticator, secret, attributes);
default:
return RadiusPacket.createAccessReject(identifier, requestAuthenticator, secret, attributes);
}
}
private cleanupDuplicateCache(): void {
const now = Date.now();
const expiry = this.options.duplicateDetectionWindow;
for (const [key, entry] of this.recentRequests) {
if (now - entry.timestamp > expiry) {
this.recentRequests.delete(key);
}
}
}
}
export default RadiusServer;

9
ts_server/index.ts Normal file
View File

@@ -0,0 +1,9 @@
// RADIUS Server Module
// Implements RFC 2865 (Authentication) and RFC 2866 (Accounting)
export * from './interfaces.js';
export { RadiusServer } from './classes.radiusserver.js';
export { RadiusPacket } from './classes.radiuspacket.js';
export { RadiusAttributes } from './classes.radiusattributes.js';
export { RadiusAuthenticator } from './classes.radiusauthenticator.js';
export { RadiusSecrets } from './classes.radiussecrets.js';

140
ts_server/interfaces.ts Normal file
View File

@@ -0,0 +1,140 @@
/**
* RADIUS Server Interfaces
* Server-specific types for handling authentication and accounting
*/
import type {
ERadiusCode,
EServiceType,
EFramedProtocol,
ENasPortType,
EAcctStatusType,
EAcctAuthentic,
EAcctTerminateCause,
IRadiusPacket,
IVendorSpecificAttribute,
} from '../ts_shared/index.js';
// Re-export all shared types for backwards compatibility
export * from '../ts_shared/index.js';
/**
* Authentication request context
*/
export interface IAuthenticationRequest {
username: string;
password?: string; // For PAP
chapPassword?: Buffer; // For CHAP
chapChallenge?: Buffer; // For CHAP
nasIpAddress?: string;
nasIdentifier?: string;
nasPort?: number;
nasPortType?: ENasPortType;
calledStationId?: string;
callingStationId?: string;
serviceType?: EServiceType;
framedProtocol?: EFramedProtocol;
state?: Buffer;
rawPacket: IRadiusPacket;
clientAddress: string;
clientPort: number;
}
/**
* Authentication response
*/
export interface IAuthenticationResponse {
code: ERadiusCode.AccessAccept | ERadiusCode.AccessReject | ERadiusCode.AccessChallenge;
attributes?: Array<{ type: number | string; value: string | number | Buffer }>;
replyMessage?: string;
sessionTimeout?: number;
idleTimeout?: number;
state?: Buffer;
class?: Buffer;
framedIpAddress?: string;
framedIpNetmask?: string;
framedRoutes?: string[];
vendorAttributes?: IVendorSpecificAttribute[];
}
/**
* Accounting request context
*/
export interface IAccountingRequest {
statusType: EAcctStatusType;
sessionId: string;
username?: string;
nasIpAddress?: string;
nasIdentifier?: string;
nasPort?: number;
nasPortType?: ENasPortType;
delayTime?: number;
inputOctets?: number;
outputOctets?: number;
sessionTime?: number;
inputPackets?: number;
outputPackets?: number;
terminateCause?: EAcctTerminateCause;
authentic?: EAcctAuthentic;
multiSessionId?: string;
linkCount?: number;
calledStationId?: string;
callingStationId?: string;
rawPacket: IRadiusPacket;
clientAddress: string;
clientPort: number;
}
/**
* Accounting response
*/
export interface IAccountingResponse {
success: boolean;
attributes?: Array<{ type: number | string; value: string | number | Buffer }>;
}
/**
* Authentication handler function type
*/
export type TAuthenticationHandler = (request: IAuthenticationRequest) => Promise<IAuthenticationResponse>;
/**
* Accounting handler function type
*/
export type TAccountingHandler = (request: IAccountingRequest) => Promise<IAccountingResponse>;
/**
* Client secret resolver - returns secret for a given client IP
*/
export type TSecretResolver = (clientAddress: string) => string | undefined;
/**
* RADIUS Server options
*/
export interface IRadiusServerOptions {
authPort?: number;
acctPort?: number;
bindAddress?: string;
defaultSecret?: string;
secretResolver?: TSecretResolver;
authenticationHandler?: TAuthenticationHandler;
accountingHandler?: TAccountingHandler;
duplicateDetectionWindow?: number; // ms
maxPacketSize?: number;
}
/**
* RADIUS server statistics
*/
export interface IRadiusServerStats {
authRequests: number;
authAccepts: number;
authRejects: number;
authChallenges: number;
authInvalidPackets: number;
authUnknownClients: number;
acctRequests: number;
acctResponses: number;
acctInvalidPackets: number;
acctUnknownClients: number;
}

7
ts_server/plugins.ts Normal file
View File

@@ -0,0 +1,7 @@
import * as crypto from 'crypto';
import * as dgram from 'dgram';
export {
crypto,
dgram,
};

135
ts_server/readme.md Normal file
View File

@@ -0,0 +1,135 @@
# @push.rocks/smartradius/server
> 🖥️ RADIUS Server Implementation - Full RFC 2865/2866 compliant authentication and accounting server
## Overview
This module provides a complete RADIUS server implementation supporting both authentication (RFC 2865) and accounting (RFC 2866) protocols. It handles PAP and CHAP authentication, accounting session tracking, and includes duplicate detection with response caching.
## Features
-**PAP Authentication** - Password Authentication Protocol with RFC-compliant encryption
-**CHAP Authentication** - Challenge-Handshake Authentication Protocol
-**Accounting** - Session start/stop/interim-update tracking
-**Duplicate Detection** - Automatic response caching for retransmitted requests
-**Per-Client Secrets** - Support for different shared secrets per NAS
-**Statistics** - Built-in request/response counters
-**VSA Support** - Vendor-Specific Attributes handling
-**Message-Authenticator** - HMAC-MD5 for EAP support
## Exports
### Classes
| Class | Description |
|-------|-------------|
| `RadiusServer` | Main server class handling authentication and accounting |
| `RadiusPacket` | Packet encoder/decoder for RADIUS protocol |
| `RadiusAttributes` | Attribute parsing and encoding utilities |
| `RadiusAuthenticator` | Cryptographic operations (PAP encryption, CHAP, authenticators) |
| `RadiusSecrets` | Client secret management |
### Interfaces (Server-Specific)
| Interface | Description |
|-----------|-------------|
| `IRadiusServerOptions` | Server configuration options |
| `IRadiusServerStats` | Server statistics counters |
| `IAuthenticationRequest` | Request context passed to auth handler |
| `IAuthenticationResponse` | Response from auth handler |
| `IAccountingRequest` | Request context passed to accounting handler |
| `IAccountingResponse` | Response from accounting handler |
| `TAuthenticationHandler` | Handler function type for authentication |
| `TAccountingHandler` | Handler function type for accounting |
| `TSecretResolver` | Function type for resolving client secrets |
## Usage
```typescript
import { RadiusServer, ERadiusCode } from '@push.rocks/smartradius';
const server = new RadiusServer({
authPort: 1812,
acctPort: 1813,
defaultSecret: 'shared-secret',
authenticationHandler: async (request) => {
// PAP authentication
if (request.password === 'correct-password') {
return {
code: ERadiusCode.AccessAccept,
replyMessage: 'Welcome!',
sessionTimeout: 3600,
};
}
// CHAP authentication
if (request.chapPassword && request.chapChallenge) {
const isValid = RadiusAuthenticator.verifyChapResponse(
request.chapPassword,
request.chapChallenge,
'expected-password'
);
if (isValid) {
return { code: ERadiusCode.AccessAccept };
}
}
return { code: ERadiusCode.AccessReject };
},
accountingHandler: async (request) => {
console.log(`Session ${request.sessionId}: ${request.statusType}`);
return { success: true };
},
});
await server.start();
```
## Low-Level Packet Operations
```typescript
import {
RadiusPacket,
RadiusAuthenticator,
RadiusAttributes,
ERadiusAttributeType,
} from '@push.rocks/smartradius';
// Decode incoming packet
const packet = RadiusPacket.decodeAndParse(buffer);
// Encrypt PAP password
const encrypted = RadiusAuthenticator.encryptPassword(
password, authenticator, secret
);
// Verify CHAP response
const valid = RadiusAuthenticator.verifyChapResponse(
chapPassword, challenge, expectedPassword
);
// Create Vendor-Specific Attribute
const vsa = RadiusAttributes.createVendorAttribute(
9, // Cisco vendor ID
1, // Vendor type
Buffer.from('value')
);
```
## Server Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `authPort` | number | 1812 | Authentication port |
| `acctPort` | number | 1813 | Accounting port |
| `bindAddress` | string | '0.0.0.0' | Address to bind to |
| `defaultSecret` | string | - | Default shared secret |
| `secretResolver` | function | - | Per-client secret resolver |
| `duplicateDetectionWindow` | number | 10000 | Duplicate detection window (ms) |
| `maxPacketSize` | number | 4096 | Maximum packet size |
## Re-exports
This module re-exports all types from `ts_shared` for convenience, so you can import everything from a single location.

1
ts_server/tspublish.json Normal file
View File

@@ -0,0 +1 @@
{ "order": 2 }