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:
303
ts_server/classes.radiusattributes.ts
Normal file
303
ts_server/classes.radiusattributes.ts
Normal 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;
|
||||
Reference in New Issue
Block a user