feat(smartradius): Implement full RADIUS server and client with RFC 2865/2866 compliance, including packet handling, authenticators, attributes, secrets manager, client APIs, and comprehensive tests and documentation

This commit is contained in:
2026-02-01 17:40:36 +00:00
parent 5a6a3cf66e
commit be9f49fff9
45 changed files with 11694 additions and 70 deletions

View File

@@ -0,0 +1,531 @@
import * as plugins from './plugins.js';
import type {
IRadiusClientOptions,
IClientAuthRequest,
IClientAuthResponse,
IClientAccountingRequest,
IClientAccountingResponse,
} from './interfaces.js';
import {
RadiusPacket,
RadiusAuthenticator,
RadiusAttributes,
ERadiusCode,
ERadiusAttributeType,
} from '../ts_server/index.js';
/**
* Pending request tracking
*/
interface IPendingRequest {
identifier: number;
requestAuthenticator: Buffer;
resolve: (response: Buffer) => void;
reject: (error: Error) => void;
timeoutId?: ReturnType<typeof setTimeout>;
retries: number;
packet: Buffer;
port: number;
}
/**
* RADIUS Client implementation
* Supports PAP, CHAP authentication and accounting
*/
export class RadiusClient {
private socket?: plugins.dgram.Socket;
private readonly options: Required<IRadiusClientOptions>;
private currentIdentifier = 0;
private readonly pendingRequests: Map<number, IPendingRequest> = new Map();
private isConnected = false;
constructor(options: IRadiusClientOptions) {
this.options = {
host: options.host,
authPort: options.authPort ?? 1812,
acctPort: options.acctPort ?? 1813,
secret: options.secret,
timeout: options.timeout ?? 5000,
retries: options.retries ?? 3,
retryDelay: options.retryDelay ?? 1000,
nasIpAddress: options.nasIpAddress ?? '0.0.0.0',
nasIdentifier: options.nasIdentifier ?? 'smartradius-client',
};
}
/**
* Connect the client (bind UDP socket)
*/
public async connect(): Promise<void> {
if (this.isConnected) {
return;
}
return new Promise((resolve, reject) => {
this.socket = plugins.dgram.createSocket('udp4');
this.socket.on('error', (err) => {
if (!this.isConnected) {
reject(err);
}
});
this.socket.on('message', (msg) => {
this.handleResponse(msg);
});
this.socket.bind(0, () => {
this.isConnected = true;
resolve();
});
});
}
/**
* Disconnect the client
*/
public async disconnect(): Promise<void> {
if (!this.isConnected || !this.socket) {
return;
}
// Reject all pending requests
for (const [, pending] of this.pendingRequests) {
if (pending.timeoutId) {
clearTimeout(pending.timeoutId);
}
pending.reject(new Error('Client disconnected'));
}
this.pendingRequests.clear();
return new Promise((resolve) => {
this.socket!.close(() => {
this.socket = undefined;
this.isConnected = false;
resolve();
});
});
}
/**
* Authenticate a user using PAP
*/
public async authenticatePap(username: string, password: string): Promise<IClientAuthResponse> {
return this.authenticate({
username,
password,
});
}
/**
* Authenticate a user using CHAP
*/
public async authenticateChap(
username: string,
password: string,
challenge?: Buffer
): Promise<IClientAuthResponse> {
// Generate challenge if not provided
const chapChallenge = challenge || plugins.crypto.randomBytes(16);
const chapId = Math.floor(Math.random() * 256);
// Calculate CHAP response
const chapResponse = RadiusAuthenticator.calculateChapResponse(chapId, password, chapChallenge);
// CHAP-Password = CHAP Ident (1 byte) + Response (16 bytes)
const chapPassword = Buffer.allocUnsafe(17);
chapPassword.writeUInt8(chapId, 0);
chapResponse.copy(chapPassword, 1);
return this.authenticate({
username,
chapPassword,
chapChallenge,
});
}
/**
* Send an authentication request
*/
public async authenticate(request: IClientAuthRequest): Promise<IClientAuthResponse> {
await this.ensureConnected();
const identifier = this.nextIdentifier();
const attributes: Array<{ type: number | string; value: string | number | Buffer }> = [];
// Add User-Name
attributes.push({ type: ERadiusAttributeType.UserName, value: request.username });
// Add NAS-IP-Address or NAS-Identifier
if (this.options.nasIpAddress && this.options.nasIpAddress !== '0.0.0.0') {
attributes.push({ type: ERadiusAttributeType.NasIpAddress, value: this.options.nasIpAddress });
}
if (this.options.nasIdentifier) {
attributes.push({ type: ERadiusAttributeType.NasIdentifier, value: this.options.nasIdentifier });
}
// Add PAP password or CHAP credentials
if (request.password !== undefined) {
// PAP - password will be encrypted in createAccessRequest
attributes.push({ type: ERadiusAttributeType.UserPassword, value: request.password });
} else if (request.chapPassword) {
// CHAP
attributes.push({ type: ERadiusAttributeType.ChapPassword, value: request.chapPassword });
if (request.chapChallenge) {
attributes.push({ type: ERadiusAttributeType.ChapChallenge, value: request.chapChallenge });
}
}
// Add optional attributes
if (request.nasPort !== undefined) {
attributes.push({ type: ERadiusAttributeType.NasPort, value: request.nasPort });
}
if (request.nasPortType !== undefined) {
attributes.push({ type: ERadiusAttributeType.NasPortType, value: request.nasPortType });
}
if (request.serviceType !== undefined) {
attributes.push({ type: ERadiusAttributeType.ServiceType, value: request.serviceType });
}
if (request.calledStationId) {
attributes.push({ type: ERadiusAttributeType.CalledStationId, value: request.calledStationId });
}
if (request.callingStationId) {
attributes.push({ type: ERadiusAttributeType.CallingStationId, value: request.callingStationId });
}
if (request.state) {
attributes.push({ type: ERadiusAttributeType.State, value: request.state });
}
// Add custom attributes
if (request.customAttributes) {
attributes.push(...request.customAttributes);
}
// Create packet
const packet = RadiusPacket.createAccessRequest(identifier, this.options.secret, attributes);
// Extract request authenticator from packet (bytes 4-20)
const requestAuthenticator = packet.subarray(4, 20);
// Send and wait for response
const responseBuffer = await this.sendRequest(
identifier,
packet,
requestAuthenticator,
this.options.authPort
);
// Verify response authenticator
if (!RadiusAuthenticator.verifyResponseAuthenticator(
responseBuffer,
requestAuthenticator,
this.options.secret
)) {
throw new Error('Invalid response authenticator');
}
// Parse response
const response = RadiusPacket.decodeAndParse(responseBuffer);
return this.buildAuthResponse(response);
}
/**
* Send an accounting request
*/
public async accounting(request: IClientAccountingRequest): Promise<IClientAccountingResponse> {
await this.ensureConnected();
const identifier = this.nextIdentifier();
const attributes: Array<{ type: number | string; value: string | number | Buffer }> = [];
// Add required attributes
attributes.push({ type: ERadiusAttributeType.AcctStatusType, value: request.statusType });
attributes.push({ type: ERadiusAttributeType.AcctSessionId, value: request.sessionId });
// Add NAS identification
if (this.options.nasIpAddress && this.options.nasIpAddress !== '0.0.0.0') {
attributes.push({ type: ERadiusAttributeType.NasIpAddress, value: this.options.nasIpAddress });
}
if (this.options.nasIdentifier) {
attributes.push({ type: ERadiusAttributeType.NasIdentifier, value: this.options.nasIdentifier });
}
// Add optional attributes
if (request.username) {
attributes.push({ type: ERadiusAttributeType.UserName, value: request.username });
}
if (request.nasPort !== undefined) {
attributes.push({ type: ERadiusAttributeType.NasPort, value: request.nasPort });
}
if (request.nasPortType !== undefined) {
attributes.push({ type: ERadiusAttributeType.NasPortType, value: request.nasPortType });
}
if (request.sessionTime !== undefined) {
attributes.push({ type: ERadiusAttributeType.AcctSessionTime, value: request.sessionTime });
}
if (request.inputOctets !== undefined) {
attributes.push({ type: ERadiusAttributeType.AcctInputOctets, value: request.inputOctets });
}
if (request.outputOctets !== undefined) {
attributes.push({ type: ERadiusAttributeType.AcctOutputOctets, value: request.outputOctets });
}
if (request.inputPackets !== undefined) {
attributes.push({ type: ERadiusAttributeType.AcctInputPackets, value: request.inputPackets });
}
if (request.outputPackets !== undefined) {
attributes.push({ type: ERadiusAttributeType.AcctOutputPackets, value: request.outputPackets });
}
if (request.terminateCause !== undefined) {
attributes.push({ type: ERadiusAttributeType.AcctTerminateCause, value: request.terminateCause });
}
if (request.calledStationId) {
attributes.push({ type: ERadiusAttributeType.CalledStationId, value: request.calledStationId });
}
if (request.callingStationId) {
attributes.push({ type: ERadiusAttributeType.CallingStationId, value: request.callingStationId });
}
// Add custom attributes
if (request.customAttributes) {
attributes.push(...request.customAttributes);
}
// Create packet
const packet = RadiusPacket.createAccountingRequest(identifier, this.options.secret, attributes);
// Extract request authenticator from packet
const requestAuthenticator = packet.subarray(4, 20);
// Send and wait for response
const responseBuffer = await this.sendRequest(
identifier,
packet,
requestAuthenticator,
this.options.acctPort
);
// Verify response authenticator
if (!RadiusAuthenticator.verifyResponseAuthenticator(
responseBuffer,
requestAuthenticator,
this.options.secret
)) {
throw new Error('Invalid response authenticator');
}
// Parse response
const response = RadiusPacket.decodeAndParse(responseBuffer);
return {
success: response.code === ERadiusCode.AccountingResponse,
attributes: response.parsedAttributes,
rawPacket: response,
};
}
/**
* Send accounting start
*/
public async accountingStart(sessionId: string, username?: string): Promise<IClientAccountingResponse> {
const { EAcctStatusType } = await import('../ts_server/index.js');
return this.accounting({
statusType: EAcctStatusType.Start,
sessionId,
username,
});
}
/**
* Send accounting stop
*/
public async accountingStop(
sessionId: string,
options?: {
username?: string;
sessionTime?: number;
inputOctets?: number;
outputOctets?: number;
terminateCause?: number;
}
): Promise<IClientAccountingResponse> {
const { EAcctStatusType } = await import('../ts_server/index.js');
return this.accounting({
statusType: EAcctStatusType.Stop,
sessionId,
...options,
});
}
/**
* Send accounting interim update
*/
public async accountingUpdate(
sessionId: string,
options?: {
username?: string;
sessionTime?: number;
inputOctets?: number;
outputOctets?: number;
}
): Promise<IClientAccountingResponse> {
const { EAcctStatusType } = await import('../ts_server/index.js');
return this.accounting({
statusType: EAcctStatusType.InterimUpdate,
sessionId,
...options,
});
}
private async ensureConnected(): Promise<void> {
if (!this.isConnected) {
await this.connect();
}
}
private nextIdentifier(): number {
this.currentIdentifier = (this.currentIdentifier + 1) % 256;
return this.currentIdentifier;
}
private sendRequest(
identifier: number,
packet: Buffer,
requestAuthenticator: Buffer,
port: number
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const pending: IPendingRequest = {
identifier,
requestAuthenticator,
resolve,
reject,
retries: 0,
packet,
port,
};
this.pendingRequests.set(identifier, pending);
this.sendWithRetry(pending);
});
}
private sendWithRetry(pending: IPendingRequest): void {
// Send packet
this.socket!.send(pending.packet, pending.port, this.options.host, (err) => {
if (err) {
this.pendingRequests.delete(pending.identifier);
pending.reject(err);
return;
}
// Set timeout
pending.timeoutId = setTimeout(() => {
pending.retries++;
if (pending.retries >= this.options.retries) {
this.pendingRequests.delete(pending.identifier);
pending.reject(new Error(`Request timed out after ${this.options.retries} retries`));
return;
}
// Exponential backoff
const delay = this.options.retryDelay * Math.pow(2, pending.retries - 1);
setTimeout(() => {
if (this.pendingRequests.has(pending.identifier)) {
this.sendWithRetry(pending);
}
}, delay);
}, this.options.timeout);
});
}
private handleResponse(msg: Buffer): void {
if (msg.length < 20) {
return;
}
const identifier = msg.readUInt8(1);
const pending = this.pendingRequests.get(identifier);
if (!pending) {
// Response for unknown request
return;
}
// Clear timeout
if (pending.timeoutId) {
clearTimeout(pending.timeoutId);
}
// Remove from pending
this.pendingRequests.delete(identifier);
// Resolve with response
pending.resolve(msg);
}
private buildAuthResponse(packet: any): IClientAuthResponse {
const code = packet.code as ERadiusCode;
const accepted = code === ERadiusCode.AccessAccept;
const rejected = code === ERadiusCode.AccessReject;
const challenged = code === ERadiusCode.AccessChallenge;
// Extract common attributes
let replyMessage: string | undefined;
let sessionTimeout: number | undefined;
let idleTimeout: number | undefined;
let state: Buffer | undefined;
let classAttr: Buffer | undefined;
let framedIpAddress: string | undefined;
let framedIpNetmask: string | undefined;
const framedRoutes: string[] = [];
for (const attr of packet.parsedAttributes) {
switch (attr.type) {
case ERadiusAttributeType.ReplyMessage:
replyMessage = attr.value as string;
break;
case ERadiusAttributeType.SessionTimeout:
sessionTimeout = attr.value as number;
break;
case ERadiusAttributeType.IdleTimeout:
idleTimeout = attr.value as number;
break;
case ERadiusAttributeType.State:
state = attr.rawValue;
break;
case ERadiusAttributeType.Class:
classAttr = attr.rawValue;
break;
case ERadiusAttributeType.FramedIpAddress:
framedIpAddress = attr.value as string;
break;
case ERadiusAttributeType.FramedIpNetmask:
framedIpNetmask = attr.value as string;
break;
case ERadiusAttributeType.FramedRoute:
framedRoutes.push(attr.value as string);
break;
}
}
return {
code,
accepted,
rejected,
challenged,
replyMessage,
sessionTimeout,
idleTimeout,
state,
class: classAttr,
framedIpAddress,
framedIpNetmask,
framedRoutes: framedRoutes.length > 0 ? framedRoutes : undefined,
attributes: packet.parsedAttributes,
rawPacket: packet,
};
}
}
export default RadiusClient;

4
ts_client/index.ts Normal file
View File

@@ -0,0 +1,4 @@
// RADIUS Client Module
export * from './interfaces.js';
export { RadiusClient } from './classes.radiusclient.js';

96
ts_client/interfaces.ts Normal file
View File

@@ -0,0 +1,96 @@
/**
* RADIUS Client Interfaces
*/
import type {
ERadiusCode,
IRadiusPacket,
IParsedAttribute,
ENasPortType,
EServiceType,
EAcctStatusType,
} from '../ts_shared/index.js';
// Re-export all shared types for backwards compatibility
export * from '../ts_shared/index.js';
/**
* RADIUS Client options
*/
export interface IRadiusClientOptions {
host: string;
authPort?: number;
acctPort?: number;
secret: string;
timeout?: number;
retries?: number;
retryDelay?: number;
nasIpAddress?: string;
nasIdentifier?: string;
}
/**
* Authentication request for the client
*/
export interface IClientAuthRequest {
username: string;
password?: string; // For PAP
chapPassword?: Buffer; // For CHAP (CHAP Ident + Response)
chapChallenge?: Buffer; // For CHAP
nasPort?: number;
nasPortType?: ENasPortType;
serviceType?: EServiceType;
calledStationId?: string;
callingStationId?: string;
state?: Buffer; // For multi-round authentication
customAttributes?: Array<{ type: number | string; value: string | number | Buffer }>;
}
/**
* Authentication response from the server
*/
export interface IClientAuthResponse {
code: ERadiusCode;
accepted: boolean;
rejected: boolean;
challenged: boolean;
replyMessage?: string;
sessionTimeout?: number;
idleTimeout?: number;
state?: Buffer;
class?: Buffer;
framedIpAddress?: string;
framedIpNetmask?: string;
framedRoutes?: string[];
attributes: IParsedAttribute[];
rawPacket: IRadiusPacket;
}
/**
* Accounting request for the client
*/
export interface IClientAccountingRequest {
statusType: EAcctStatusType;
sessionId: string;
username?: string;
nasPort?: number;
nasPortType?: ENasPortType;
sessionTime?: number;
inputOctets?: number;
outputOctets?: number;
inputPackets?: number;
outputPackets?: number;
terminateCause?: number;
calledStationId?: string;
callingStationId?: string;
customAttributes?: Array<{ type: number | string; value: string | number | Buffer }>;
}
/**
* Accounting response from the server
*/
export interface IClientAccountingResponse {
success: boolean;
attributes: IParsedAttribute[];
rawPacket: IRadiusPacket;
}

13
ts_client/plugins.ts Normal file
View File

@@ -0,0 +1,13 @@
import * as crypto from 'crypto';
import * as dgram from 'dgram';
// Import from smartpromise for deferred promises
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartdelay from '@push.rocks/smartdelay';
export {
crypto,
dgram,
smartpromise,
smartdelay,
};

151
ts_client/readme.md Normal file
View File

@@ -0,0 +1,151 @@
# @push.rocks/smartradius/client
> 📱 RADIUS Client Implementation - Connect to RADIUS servers with PAP, CHAP, and accounting support
## Overview
This module provides a RADIUS client implementation for connecting to RADIUS servers. It supports PAP and CHAP authentication methods, accounting operations, and includes automatic retry with exponential backoff.
## Features
-**PAP Authentication** - Password Authentication Protocol
-**CHAP Authentication** - Challenge-Handshake Authentication Protocol
-**Accounting** - Session start, stop, and interim updates
-**Automatic Retries** - Configurable retry count with exponential backoff
-**Timeout Handling** - Per-request timeouts
-**Custom Attributes** - Support for adding custom RADIUS attributes
-**Response Validation** - Authenticator verification for security
## Exports
### Classes
| Class | Description |
|-------|-------------|
| `RadiusClient` | Main client class for RADIUS operations |
### Interfaces (Client-Specific)
| Interface | Description |
|-----------|-------------|
| `IRadiusClientOptions` | Client configuration options |
| `IClientAuthRequest` | Authentication request parameters |
| `IClientAuthResponse` | Authentication response from server |
| `IClientAccountingRequest` | Accounting request parameters |
| `IClientAccountingResponse` | Accounting response from server |
## Usage
### Basic Authentication
```typescript
import { RadiusClient } from '@push.rocks/smartradius';
const client = new RadiusClient({
host: '192.168.1.1',
secret: 'shared-secret',
timeout: 5000,
retries: 3,
});
await client.connect();
// PAP Authentication
const papResult = await client.authenticatePap('username', 'password');
if (papResult.accepted) {
console.log('Login successful!');
console.log('Session timeout:', papResult.sessionTimeout);
}
// CHAP Authentication
const chapResult = await client.authenticateChap('username', 'password');
if (chapResult.accepted) {
console.log('CHAP login successful!');
}
await client.disconnect();
```
### Accounting
```typescript
import { RadiusClient, EAcctStatusType } from '@push.rocks/smartradius';
const client = new RadiusClient({
host: '192.168.1.1',
secret: 'shared-secret',
});
await client.connect();
// Session start
await client.accountingStart('session-123', 'username');
// Interim update
await client.accountingUpdate('session-123', {
username: 'username',
sessionTime: 300,
inputOctets: 1024000,
outputOctets: 2048000,
});
// Session stop
await client.accountingStop('session-123', {
username: 'username',
sessionTime: 600,
inputOctets: 2048000,
outputOctets: 4096000,
terminateCause: 1, // User-Request
});
await client.disconnect();
```
### Custom Attributes
```typescript
const result = await client.authenticate({
username: 'user',
password: 'pass',
nasPort: 1,
calledStationId: 'AA-BB-CC-DD-EE-FF',
callingStationId: '11-22-33-44-55-66',
customAttributes: [
{ type: 'Service-Type', value: 2 }, // Framed
{ type: 26, value: Buffer.from('vendor-data') }, // VSA
],
});
```
## Client Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `host` | string | *required* | RADIUS server address |
| `authPort` | number | 1812 | Authentication port |
| `acctPort` | number | 1813 | Accounting port |
| `secret` | string | *required* | Shared secret |
| `timeout` | number | 5000 | Request timeout (ms) |
| `retries` | number | 3 | Number of retries |
| `retryDelay` | number | 1000 | Base delay between retries (ms) |
| `nasIpAddress` | string | '0.0.0.0' | NAS-IP-Address attribute |
| `nasIdentifier` | string | 'smartradius-client' | NAS-Identifier attribute |
## Response Properties
### IClientAuthResponse
| Property | Type | Description |
|----------|------|-------------|
| `code` | ERadiusCode | Response packet code |
| `accepted` | boolean | True if Access-Accept |
| `rejected` | boolean | True if Access-Reject |
| `challenged` | boolean | True if Access-Challenge |
| `replyMessage` | string | Reply-Message attribute |
| `sessionTimeout` | number | Session-Timeout in seconds |
| `framedIpAddress` | string | Assigned IP address |
| `attributes` | IParsedAttribute[] | All response attributes |
## Re-exports
This module re-exports all types from `ts_shared` for convenience.

1
ts_client/tspublish.json Normal file
View File

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