Files
smartradius/ts_server/classes.radiusserver.ts

650 lines
22 KiB
TypeScript
Raw Normal View History

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;