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;
|
||||
302
ts_server/classes.radiusauthenticator.ts
Normal file
302
ts_server/classes.radiusauthenticator.ts
Normal 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;
|
||||
426
ts_server/classes.radiuspacket.ts
Normal file
426
ts_server/classes.radiuspacket.ts
Normal 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;
|
||||
116
ts_server/classes.radiussecrets.ts
Normal file
116
ts_server/classes.radiussecrets.ts
Normal 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;
|
||||
649
ts_server/classes.radiusserver.ts
Normal file
649
ts_server/classes.radiusserver.ts
Normal 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
9
ts_server/index.ts
Normal 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
140
ts_server/interfaces.ts
Normal 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
7
ts_server/plugins.ts
Normal 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
135
ts_server/readme.md
Normal 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
1
ts_server/tspublish.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "order": 2 }
|
||||
Reference in New Issue
Block a user