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;