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:
17
changelog.md
Normal file
17
changelog.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-01 - 1.1.0 - 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
|
||||
|
||||
- Add ts_shared module with protocol enums and core interfaces (RFC 2865/2866)
|
||||
- Add ts_server module: RadiusServer implementation, RadiusPacket encoder/decoder, RadiusAttributes, RadiusAuthenticator, RadiusSecrets and server README
|
||||
- Add ts_client module: RadiusClient implementation, client interfaces, plugins and client README
|
||||
- Add comprehensive unit and integration tests (PAP, CHAP, packet, attributes, authenticator, accounting, timeouts, client-server integration) under test/
|
||||
- Update package.json: description, build script (tsfolders), bumped devDependencies, new runtime dependencies (smartdelay, smartpromise), files list and keywords
|
||||
- Add docs and helper files: README updates, ts module READMEs, readme.hints.md, and tspublish metadata for subpackages
|
||||
|
||||
## 2026-02-01 - 1.0.1 - initial release
|
||||
Initial commit and project scaffold.
|
||||
|
||||
- Initial commit ("initial")
|
||||
- Repository initialized and first release tagged as 1.0.1
|
||||
@@ -11,7 +11,11 @@
|
||||
"projectDomain": "push.rocks"
|
||||
},
|
||||
"release": {
|
||||
"accessLevel": "public"
|
||||
"accessLevel": "public",
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
]
|
||||
}
|
||||
},
|
||||
"@ship.zone/szci": {
|
||||
|
||||
41
package.json
41
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "@push.rocks/smartradius",
|
||||
"version": "1.0.1",
|
||||
"private": false,
|
||||
"description": "a radius server implementation",
|
||||
"description": "A RADIUS server and client implementation for Node.js with full RFC 2865/2866 compliance",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"type": "module",
|
||||
@@ -10,16 +10,41 @@
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "(tstest test/ --verbose --logfile --timeout 60)",
|
||||
"build": "(tsbuild --web --allowimplicitany)",
|
||||
"build": "(tsbuild tsfolders --allowimplicitany)",
|
||||
"buildDocs": "(tsdoc)"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^3.1.2",
|
||||
"@git.zone/tsrun": "^2.0.0",
|
||||
"@git.zone/tstest": "^3.1.3",
|
||||
"@types/node": "^24.10.1"
|
||||
"@git.zone/tsbuild": "^4.1.2",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tstest": "^3.1.8",
|
||||
"@types/node": "^25.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@push.rocks/smartpath": "^6.0.0"
|
||||
}
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
"@push.rocks/smartpromise": "^4.2.3"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"ts_shared/**/*",
|
||||
"ts_server/**/*",
|
||||
"ts_client/**/*",
|
||||
"dist_ts/**/*",
|
||||
"dist_ts_shared/**/*",
|
||||
"dist_ts_server/**/*",
|
||||
"dist_ts_client/**/*",
|
||||
"readme.md"
|
||||
],
|
||||
"keywords": [
|
||||
"radius",
|
||||
"authentication",
|
||||
"accounting",
|
||||
"aaa",
|
||||
"pap",
|
||||
"chap",
|
||||
"rfc2865",
|
||||
"rfc2866",
|
||||
"network",
|
||||
"server",
|
||||
"client"
|
||||
]
|
||||
}
|
||||
|
||||
69
pnpm-lock.yaml
generated
69
pnpm-lock.yaml
generated
@@ -8,22 +8,25 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@push.rocks/smartpath':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
'@push.rocks/smartdelay':
|
||||
specifier: ^3.0.5
|
||||
version: 3.0.5
|
||||
'@push.rocks/smartpromise':
|
||||
specifier: ^4.2.3
|
||||
version: 4.2.3
|
||||
devDependencies:
|
||||
'@git.zone/tsbuild':
|
||||
specifier: ^3.1.2
|
||||
version: 3.1.4
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2
|
||||
'@git.zone/tsrun':
|
||||
specifier: ^2.0.0
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
'@git.zone/tstest':
|
||||
specifier: ^3.1.3
|
||||
specifier: ^3.1.8
|
||||
version: 3.1.8(socks@2.8.7)(typescript@5.9.3)
|
||||
'@types/node':
|
||||
specifier: ^24.10.1
|
||||
version: 24.10.9
|
||||
specifier: ^25.2.0
|
||||
version: 25.2.0
|
||||
|
||||
packages:
|
||||
|
||||
@@ -405,8 +408,8 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@git.zone/tsbuild@3.1.4':
|
||||
resolution: {integrity: sha512-nZ5UaOx2GZbhW9T9zWzKiWMZOa6hjX9s9AeTo4SLdrjqCCsSZDtORl4hOpGXEwHbXjiMkbARTQbRcmcCU7HUkw==}
|
||||
'@git.zone/tsbuild@4.1.2':
|
||||
resolution: {integrity: sha512-S518ulKveO76pS6jrAELrnFaCw5nDAIZD9j6QzVmLYDiZuJmlRwPK3/2E8ugQ+b7ffpkwJ9MT685ooEGDcWQ4Q==}
|
||||
hasBin: true
|
||||
|
||||
'@git.zone/tsbundle@2.8.3':
|
||||
@@ -1369,8 +1372,8 @@ packages:
|
||||
'@types/node@22.19.7':
|
||||
resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
|
||||
|
||||
'@types/node@24.10.9':
|
||||
resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==}
|
||||
'@types/node@25.2.0':
|
||||
resolution: {integrity: sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==}
|
||||
|
||||
'@types/ping@0.4.4':
|
||||
resolution: {integrity: sha512-ifvo6w2f5eJYlXm+HiVx67iJe8WZp87sfa683nlqED5Vnt9Z93onkokNoWqOG21EaE8fMxyKPobE+mkPEyxsdw==}
|
||||
@@ -4167,7 +4170,7 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@git.zone/tsbuild@3.1.4':
|
||||
'@git.zone/tsbuild@4.1.2':
|
||||
dependencies:
|
||||
'@git.zone/tspublish': 1.11.0
|
||||
'@push.rocks/early': 4.0.4
|
||||
@@ -5845,27 +5848,27 @@ snapshots:
|
||||
|
||||
'@types/bn.js@5.2.0':
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/body-parser@1.19.6':
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 24.10.9
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/buffer-json@2.0.3': {}
|
||||
|
||||
'@types/clean-css@4.2.11':
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
'@types/node': 25.2.0
|
||||
source-map: 0.6.1
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/debug@4.1.12':
|
||||
dependencies:
|
||||
@@ -5873,7 +5876,7 @@ snapshots:
|
||||
|
||||
'@types/dns-packet@5.6.5':
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/elliptic@6.4.18':
|
||||
dependencies:
|
||||
@@ -5881,7 +5884,7 @@ snapshots:
|
||||
|
||||
'@types/express-serve-static-core@5.1.1':
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
'@types/node': 25.2.0
|
||||
'@types/qs': 6.14.0
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 1.2.1
|
||||
@@ -5895,7 +5898,7 @@ snapshots:
|
||||
'@types/fs-extra@11.0.4':
|
||||
dependencies:
|
||||
'@types/jsonfile': 6.1.4
|
||||
'@types/node': 24.10.9
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
dependencies:
|
||||
@@ -5917,7 +5920,7 @@ snapshots:
|
||||
|
||||
'@types/jsonfile@6.1.4':
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
dependencies:
|
||||
@@ -5931,17 +5934,17 @@ snapshots:
|
||||
|
||||
'@types/mute-stream@0.0.4':
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/node-forge@1.3.14':
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/node@22.19.7':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@24.10.9':
|
||||
'@types/node@25.2.0':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
@@ -5959,22 +5962,22 @@ snapshots:
|
||||
|
||||
'@types/send@1.2.1':
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/serve-static@2.2.0':
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 24.10.9
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/symbol-tree@3.2.5': {}
|
||||
|
||||
'@types/tar-stream@3.1.4':
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/through2@2.0.41':
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
@@ -6000,11 +6003,11 @@ snapshots:
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
'@types/node': 25.2.0
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
'@types/node': 25.2.0
|
||||
optional: true
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
@@ -6417,7 +6420,7 @@ snapshots:
|
||||
engine.io@6.6.4:
|
||||
dependencies:
|
||||
'@types/cors': 2.8.19
|
||||
'@types/node': 24.10.9
|
||||
'@types/node': 25.2.0
|
||||
accepts: 1.3.8
|
||||
base64id: 2.0.0
|
||||
cookie: 0.7.2
|
||||
|
||||
111
readme.hints.md
Normal file
111
readme.hints.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Project Hints - smartradius
|
||||
|
||||
## Project Status
|
||||
- **Current State**: Fully implemented RADIUS server and client
|
||||
- **Purpose**: RADIUS protocol implementation for network AAA (Authentication, Authorization, Accounting)
|
||||
- **Version**: 1.0.1
|
||||
- **RFC Compliance**: RFC 2865 (Authentication) and RFC 2866 (Accounting)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Module Structure
|
||||
```
|
||||
ts_server/ (order: 1) - RADIUS Server implementation
|
||||
ts_client/ (order: 2) - RADIUS Client implementation
|
||||
ts/ (order: 3) - Main exports (re-exports server + client)
|
||||
```
|
||||
|
||||
### Key Classes
|
||||
|
||||
#### Server Module (ts_server/)
|
||||
- `RadiusServer` - Main server class with UDP listeners for auth (1812) and accounting (1813)
|
||||
- `RadiusPacket` - Packet encoding/decoding per RFC 2865 Section 3
|
||||
- `RadiusAttributes` - Attribute dictionary with all standard RFC 2865/2866 attributes
|
||||
- `RadiusAuthenticator` - Cryptographic operations (PAP, CHAP, MD5, HMAC-MD5)
|
||||
- `RadiusSecrets` - Per-client shared secret management
|
||||
|
||||
#### Client Module (ts_client/)
|
||||
- `RadiusClient` - Client with PAP/CHAP auth and accounting, timeout/retry support
|
||||
|
||||
## Implemented Features
|
||||
|
||||
### Authentication (RFC 2865)
|
||||
- PAP (Password Authentication Protocol) with MD5-based encryption
|
||||
- CHAP (Challenge-Handshake Authentication Protocol)
|
||||
- Access-Request/Accept/Reject/Challenge packet handling
|
||||
- Message-Authenticator (HMAC-MD5) for EAP support
|
||||
- All standard attributes (1-63) plus EAP support (79, 80)
|
||||
|
||||
### Accounting (RFC 2866)
|
||||
- Accounting-Request/Response packets
|
||||
- Status types: Start, Stop, Interim-Update, Accounting-On/Off
|
||||
- Full session tracking attributes
|
||||
- Termination cause codes
|
||||
|
||||
### Protocol Features
|
||||
- Duplicate request detection and response caching
|
||||
- Response authenticator verification
|
||||
- Configurable timeout and retry with exponential backoff
|
||||
- Per-client shared secret management
|
||||
- Vendor-Specific Attributes (VSA) support
|
||||
|
||||
## Dependencies
|
||||
```json
|
||||
{
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
"@push.rocks/smartpromise": "^4.2.3"
|
||||
}
|
||||
```
|
||||
|
||||
Node.js built-ins: `dgram` (UDP), `crypto` (MD5/HMAC)
|
||||
|
||||
## Build System
|
||||
- Uses `@git.zone/tsbuild` v4.x with tsfolders mode
|
||||
- Build command: `pnpm build` (compiles ts_server → ts_client → ts)
|
||||
- Test command: `pnpm test`
|
||||
|
||||
## Test Coverage
|
||||
- 92 tests across 9 test files
|
||||
- Server tests: packet, attributes, authenticator, PAP, CHAP, accounting
|
||||
- Client tests: client functionality, timeout/retry, integration
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Server
|
||||
```typescript
|
||||
import { RadiusServer, ERadiusCode } from '@push.rocks/smartradius';
|
||||
|
||||
const server = new RadiusServer({
|
||||
authPort: 1812,
|
||||
acctPort: 1813,
|
||||
defaultSecret: 'shared-secret',
|
||||
authenticationHandler: async (request) => {
|
||||
if (request.username === 'user' && request.password === 'pass') {
|
||||
return { code: ERadiusCode.AccessAccept };
|
||||
}
|
||||
return { code: ERadiusCode.AccessReject };
|
||||
},
|
||||
});
|
||||
await server.start();
|
||||
```
|
||||
|
||||
### Client
|
||||
```typescript
|
||||
import { RadiusClient } from '@push.rocks/smartradius';
|
||||
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
secret: 'shared-secret',
|
||||
});
|
||||
await client.connect();
|
||||
const response = await client.authenticatePap('user', 'pass');
|
||||
console.log(response.accepted);
|
||||
```
|
||||
|
||||
## RFC Specifications
|
||||
Downloaded to `./spec/`:
|
||||
- `rfc2865.txt` - RADIUS Authentication
|
||||
- `rfc2866.txt` - RADIUS Accounting
|
||||
|
||||
## Last Updated
|
||||
2026-02-01 - Full implementation complete with RFC 2865/2866 compliance
|
||||
323
readme.md
323
readme.md
@@ -1,5 +1,322 @@
|
||||
# @push.rocks/smartradius
|
||||
a radius server implementation
|
||||
|
||||
## How to create the docs
|
||||
To create docs run gitzone aidoc.
|
||||
A TypeScript RADIUS server and client library with full RFC 2865/2866 compliance for network authentication, authorization, and accounting (AAA).
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## Install
|
||||
|
||||
Install via npm or pnpm:
|
||||
|
||||
```bash
|
||||
npm install @push.rocks/smartradius
|
||||
# or
|
||||
pnpm add @push.rocks/smartradius
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
This module provides a TypeScript-based RADIUS (Remote Authentication Dial-In User Service) server and client implementation. RADIUS is a networking protocol that provides centralized Authentication, Authorization, and Accounting (AAA) management for users who connect and use a network service.
|
||||
|
||||
### What is RADIUS?
|
||||
|
||||
RADIUS is an industry-standard protocol used for:
|
||||
|
||||
- 🔐 **Authentication** - Verifying user credentials (username/password via PAP or CHAP)
|
||||
- 🎫 **Authorization** - Determining what resources authenticated users can access
|
||||
- 📊 **Accounting** - Tracking network usage for billing, auditing, and statistics
|
||||
|
||||
### Common Use Cases
|
||||
|
||||
- 📡 **Enterprise Wi-Fi** - 802.1X authentication for wireless networks
|
||||
- 🔒 **VPN Access** - Authenticating remote users connecting via VPN
|
||||
- 🛡️ **Network Access Control** - Managing who can access network resources
|
||||
- 🌐 **ISP Authentication** - Authenticating dial-up and broadband users
|
||||
- 📱 **Device Authentication** - IoT and network device access control
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────┐ RADIUS ┌─────────────────┐ Auth ┌──────────────┐
|
||||
│ Client │ ◄──────────────► │ RADIUS Server │ ◄────────────► │ User Store │
|
||||
│ (NAS/AP) │ UDP 1812/1813 │ (smartradius) │ (Backend) │ (LDAP/DB) │
|
||||
└─────────────┘ └─────────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
### Creating a RADIUS Server
|
||||
|
||||
```typescript
|
||||
import { RadiusServer, ERadiusCode } from '@push.rocks/smartradius';
|
||||
|
||||
// Create a RADIUS server
|
||||
const server = new RadiusServer({
|
||||
authPort: 1812, // Authentication port (default)
|
||||
acctPort: 1813, // Accounting port (default)
|
||||
bindAddress: '0.0.0.0', // Listen on all interfaces
|
||||
defaultSecret: 'shared-secret-with-clients',
|
||||
|
||||
// Authentication handler
|
||||
authenticationHandler: async (request) => {
|
||||
console.log(`Auth request from ${request.username}`);
|
||||
|
||||
// PAP authentication
|
||||
if (request.password !== undefined) {
|
||||
if (request.username === 'testuser' && request.password === 'testpass') {
|
||||
return {
|
||||
code: ERadiusCode.AccessAccept,
|
||||
replyMessage: 'Welcome!',
|
||||
sessionTimeout: 3600,
|
||||
framedIpAddress: '10.0.0.100',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// CHAP authentication
|
||||
if (request.chapPassword && request.chapChallenge) {
|
||||
const { RadiusAuthenticator } = await import('@push.rocks/smartradius');
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(
|
||||
request.chapPassword,
|
||||
request.chapChallenge,
|
||||
'expected-password'
|
||||
);
|
||||
if (isValid) {
|
||||
return { code: ERadiusCode.AccessAccept };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code: ERadiusCode.AccessReject,
|
||||
replyMessage: 'Invalid credentials',
|
||||
};
|
||||
},
|
||||
|
||||
// Accounting handler
|
||||
accountingHandler: async (request) => {
|
||||
console.log(`Accounting ${request.statusType}: session ${request.sessionId}`);
|
||||
// Record accounting data to database
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
// Start the server
|
||||
await server.start();
|
||||
console.log('RADIUS server running on ports 1812/1813');
|
||||
|
||||
// Per-client secrets (optional)
|
||||
server.setClientSecret('192.168.1.100', 'client-specific-secret');
|
||||
|
||||
// Get statistics
|
||||
const stats = server.getStats();
|
||||
console.log(`Processed ${stats.authRequests} auth requests`);
|
||||
|
||||
// Stop the server
|
||||
await server.stop();
|
||||
```
|
||||
|
||||
### Creating a RADIUS Client
|
||||
|
||||
```typescript
|
||||
import { RadiusClient, EAcctStatusType } from '@push.rocks/smartradius';
|
||||
|
||||
// Create a RADIUS client
|
||||
const client = new RadiusClient({
|
||||
host: '192.168.1.1',
|
||||
authPort: 1812,
|
||||
acctPort: 1813,
|
||||
secret: 'shared-secret',
|
||||
timeout: 5000, // 5 second timeout
|
||||
retries: 3, // Retry 3 times
|
||||
nasIdentifier: 'my-nas-device',
|
||||
});
|
||||
|
||||
// Connect (binds UDP socket)
|
||||
await client.connect();
|
||||
|
||||
// PAP Authentication
|
||||
const papResponse = await client.authenticatePap('username', 'password');
|
||||
if (papResponse.accepted) {
|
||||
console.log('PAP Auth successful!');
|
||||
console.log('Session timeout:', papResponse.sessionTimeout);
|
||||
console.log('Assigned IP:', papResponse.framedIpAddress);
|
||||
}
|
||||
|
||||
// CHAP Authentication
|
||||
const chapResponse = await client.authenticateChap('username', 'password');
|
||||
if (chapResponse.accepted) {
|
||||
console.log('CHAP Auth successful!');
|
||||
}
|
||||
|
||||
// Authentication with custom attributes
|
||||
const customResponse = await client.authenticate({
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
nasPort: 1,
|
||||
calledStationId: 'AA-BB-CC-DD-EE-FF',
|
||||
callingStationId: '11-22-33-44-55-66',
|
||||
});
|
||||
|
||||
// Accounting: Session Start
|
||||
await client.accountingStart('session-001', 'username');
|
||||
|
||||
// Accounting: Interim Update
|
||||
await client.accountingUpdate('session-001', {
|
||||
username: 'username',
|
||||
sessionTime: 300, // 5 minutes
|
||||
inputOctets: 1024000, // 1 MB received
|
||||
outputOctets: 2048000, // 2 MB sent
|
||||
});
|
||||
|
||||
// Accounting: Session Stop
|
||||
await client.accountingStop('session-001', {
|
||||
username: 'username',
|
||||
sessionTime: 600,
|
||||
inputOctets: 2048000,
|
||||
outputOctets: 4096000,
|
||||
terminateCause: 1, // User-Request
|
||||
});
|
||||
|
||||
// Disconnect
|
||||
await client.disconnect();
|
||||
```
|
||||
|
||||
### Working with RADIUS Packets Directly
|
||||
|
||||
```typescript
|
||||
import {
|
||||
RadiusPacket,
|
||||
RadiusAuthenticator,
|
||||
RadiusAttributes,
|
||||
ERadiusCode,
|
||||
ERadiusAttributeType,
|
||||
} from '@push.rocks/smartradius';
|
||||
|
||||
// Create an Access-Request packet
|
||||
const packet = RadiusPacket.createAccessRequest(1, 'secret', [
|
||||
{ type: ERadiusAttributeType.UserName, value: 'testuser' },
|
||||
{ type: ERadiusAttributeType.UserPassword, value: 'testpass' },
|
||||
{ type: ERadiusAttributeType.NasIpAddress, value: '192.168.1.1' },
|
||||
]);
|
||||
|
||||
// Decode a received packet
|
||||
const decoded = RadiusPacket.decodeAndParse(receivedBuffer);
|
||||
console.log('Code:', RadiusPacket.getCodeName(decoded.code));
|
||||
console.log('Attributes:', decoded.parsedAttributes);
|
||||
|
||||
// Verify response authenticator
|
||||
const isValid = RadiusAuthenticator.verifyResponseAuthenticator(
|
||||
responseBuffer,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
// Encrypt/decrypt PAP password
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(password, authenticator, secret);
|
||||
const decrypted = RadiusAuthenticator.decryptPassword(encrypted, authenticator, secret);
|
||||
|
||||
// Calculate CHAP response
|
||||
const chapResponse = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
|
||||
|
||||
// Create Vendor-Specific Attribute
|
||||
const vsaAttr = RadiusAttributes.createVendorAttribute(
|
||||
9, // Cisco vendor ID
|
||||
1, // Vendor-specific type
|
||||
Buffer.from('cisco-av-pair=value')
|
||||
);
|
||||
```
|
||||
|
||||
### Supported Features
|
||||
|
||||
#### Authentication (RFC 2865)
|
||||
- ✅ PAP (Password Authentication Protocol)
|
||||
- ✅ CHAP (Challenge-Handshake Authentication Protocol)
|
||||
- ✅ Access-Request, Access-Accept, Access-Reject, Access-Challenge packets
|
||||
- ✅ Message-Authenticator (HMAC-MD5) for EAP support
|
||||
- ✅ All standard attributes (1-63) plus EAP attributes (79, 80)
|
||||
|
||||
#### Accounting (RFC 2866)
|
||||
- ✅ Accounting-Request, Accounting-Response packets
|
||||
- ✅ Status types: Start, Stop, Interim-Update, Accounting-On/Off
|
||||
- ✅ Session tracking: time, octets, packets
|
||||
- ✅ All termination cause codes
|
||||
|
||||
#### Protocol Features
|
||||
- ✅ Duplicate request detection with response caching
|
||||
- ✅ Response authenticator verification
|
||||
- ✅ Configurable timeout and retry with exponential backoff
|
||||
- ✅ Per-client shared secret management
|
||||
- ✅ Vendor-Specific Attributes (VSA) support
|
||||
|
||||
### Enums and Types
|
||||
|
||||
```typescript
|
||||
// Packet codes
|
||||
enum ERadiusCode {
|
||||
AccessRequest = 1,
|
||||
AccessAccept = 2,
|
||||
AccessReject = 3,
|
||||
AccountingRequest = 4,
|
||||
AccountingResponse = 5,
|
||||
AccessChallenge = 11,
|
||||
}
|
||||
|
||||
// Accounting status types
|
||||
enum EAcctStatusType {
|
||||
Start = 1,
|
||||
Stop = 2,
|
||||
InterimUpdate = 3,
|
||||
AccountingOn = 7,
|
||||
AccountingOff = 8,
|
||||
}
|
||||
|
||||
// Termination causes
|
||||
enum EAcctTerminateCause {
|
||||
UserRequest = 1,
|
||||
LostCarrier = 2,
|
||||
IdleTimeout = 4,
|
||||
SessionTimeout = 5,
|
||||
AdminReset = 6,
|
||||
// ... and more
|
||||
}
|
||||
```
|
||||
|
||||
## Module Structure
|
||||
|
||||
This library is organized into sub-modules for clean separation of concerns:
|
||||
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| `ts_shared` | Protocol definitions - RFC enums and core interfaces |
|
||||
| `ts_server` | Server implementation - RadiusServer, packet handling, crypto |
|
||||
| `ts_client` | Client implementation - RadiusClient with retry logic |
|
||||
| `ts` | Main entry point - re-exports everything |
|
||||
|
||||
## RFC Compliance
|
||||
|
||||
This implementation follows:
|
||||
- **RFC 2865** - Remote Authentication Dial In User Service (RADIUS)
|
||||
- **RFC 2866** - RADIUS Accounting
|
||||
|
||||
The RFC specification files are included in the `./spec/` directory for reference.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
|
||||
4259
spec/rfc2865.txt
Normal file
4259
spec/rfc2865.txt
Normal file
File diff suppressed because it is too large
Load Diff
1571
spec/rfc2866.txt
Normal file
1571
spec/rfc2866.txt
Normal file
File diff suppressed because it is too large
Load Diff
167
test/client/test.client.ts
Normal file
167
test/client/test.client.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { RadiusClient } from '../../ts_client/index.js';
|
||||
import {
|
||||
RadiusServer,
|
||||
ERadiusCode,
|
||||
EAcctStatusType,
|
||||
} from '../../ts_server/index.js';
|
||||
|
||||
// Test server and client instances
|
||||
let server: RadiusServer;
|
||||
let client: RadiusClient;
|
||||
const TEST_SECRET = 'testing123';
|
||||
const TEST_AUTH_PORT = 18120;
|
||||
const TEST_ACCT_PORT = 18130;
|
||||
|
||||
tap.test('setup - create server and client', async () => {
|
||||
// Create server with authentication handler
|
||||
server = new RadiusServer({
|
||||
authPort: TEST_AUTH_PORT,
|
||||
acctPort: TEST_ACCT_PORT,
|
||||
defaultSecret: TEST_SECRET,
|
||||
authenticationHandler: async (request) => {
|
||||
// Simple handler: accept user/password, reject others
|
||||
if (request.username === 'testuser' && request.password === 'testpass') {
|
||||
return {
|
||||
code: ERadiusCode.AccessAccept,
|
||||
replyMessage: 'Welcome!',
|
||||
sessionTimeout: 3600,
|
||||
};
|
||||
}
|
||||
|
||||
// Test CHAP
|
||||
if (request.username === 'chapuser' && request.chapPassword && request.chapChallenge) {
|
||||
const { RadiusAuthenticator } = await import('../../ts_server/index.js');
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(
|
||||
request.chapPassword,
|
||||
request.chapChallenge,
|
||||
'chappass'
|
||||
);
|
||||
if (isValid) {
|
||||
return {
|
||||
code: ERadiusCode.AccessAccept,
|
||||
replyMessage: 'CHAP OK',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code: ERadiusCode.AccessReject,
|
||||
replyMessage: 'Invalid credentials',
|
||||
};
|
||||
},
|
||||
accountingHandler: async (request) => {
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
await server.start();
|
||||
|
||||
// Create client
|
||||
client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: TEST_AUTH_PORT,
|
||||
acctPort: TEST_ACCT_PORT,
|
||||
secret: TEST_SECRET,
|
||||
timeout: 2000,
|
||||
retries: 2,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
});
|
||||
|
||||
tap.test('should authenticate with PAP - valid credentials', async () => {
|
||||
const response = await client.authenticatePap('testuser', 'testpass');
|
||||
|
||||
expect(response.accepted).toBeTruthy();
|
||||
expect(response.rejected).toBeFalsy();
|
||||
expect(response.code).toEqual(ERadiusCode.AccessAccept);
|
||||
expect(response.replyMessage).toEqual('Welcome!');
|
||||
expect(response.sessionTimeout).toEqual(3600);
|
||||
});
|
||||
|
||||
tap.test('should reject PAP - invalid credentials', async () => {
|
||||
const response = await client.authenticatePap('testuser', 'wrongpass');
|
||||
|
||||
expect(response.accepted).toBeFalsy();
|
||||
expect(response.rejected).toBeTruthy();
|
||||
expect(response.code).toEqual(ERadiusCode.AccessReject);
|
||||
expect(response.replyMessage).toEqual('Invalid credentials');
|
||||
});
|
||||
|
||||
tap.test('should authenticate with CHAP - valid credentials', async () => {
|
||||
const response = await client.authenticateChap('chapuser', 'chappass');
|
||||
|
||||
expect(response.accepted).toBeTruthy();
|
||||
expect(response.rejected).toBeFalsy();
|
||||
expect(response.code).toEqual(ERadiusCode.AccessAccept);
|
||||
});
|
||||
|
||||
tap.test('should reject CHAP - invalid credentials', async () => {
|
||||
const response = await client.authenticateChap('chapuser', 'wrongpass');
|
||||
|
||||
expect(response.accepted).toBeFalsy();
|
||||
expect(response.rejected).toBeTruthy();
|
||||
expect(response.code).toEqual(ERadiusCode.AccessReject);
|
||||
});
|
||||
|
||||
tap.test('should send accounting start', async () => {
|
||||
const response = await client.accountingStart('session-001', 'testuser');
|
||||
|
||||
expect(response.success).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should send accounting update', async () => {
|
||||
const response = await client.accountingUpdate('session-001', {
|
||||
username: 'testuser',
|
||||
sessionTime: 1800,
|
||||
inputOctets: 512000,
|
||||
outputOctets: 1024000,
|
||||
});
|
||||
|
||||
expect(response.success).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should send accounting stop', async () => {
|
||||
const response = await client.accountingStop('session-001', {
|
||||
username: 'testuser',
|
||||
sessionTime: 3600,
|
||||
inputOctets: 1024000,
|
||||
outputOctets: 2048000,
|
||||
terminateCause: 1, // User-Request
|
||||
});
|
||||
|
||||
expect(response.success).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should send custom accounting request', async () => {
|
||||
const response = await client.accounting({
|
||||
statusType: EAcctStatusType.Start,
|
||||
sessionId: 'custom-session',
|
||||
username: 'customuser',
|
||||
nasPort: 5060,
|
||||
calledStationId: 'called-001',
|
||||
callingStationId: 'calling-002',
|
||||
});
|
||||
|
||||
expect(response.success).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should handle authentication with custom attributes', async () => {
|
||||
const response = await client.authenticate({
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
nasPort: 1,
|
||||
calledStationId: 'test-station',
|
||||
callingStationId: '192.168.1.100',
|
||||
});
|
||||
|
||||
expect(response.accepted).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('teardown - cleanup server and client', async () => {
|
||||
await client.disconnect();
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
304
test/client/test.integration.ts
Normal file
304
test/client/test.integration.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { RadiusClient } from '../../ts_client/index.js';
|
||||
import {
|
||||
RadiusServer,
|
||||
ERadiusCode,
|
||||
EAcctStatusType,
|
||||
EAcctTerminateCause,
|
||||
RadiusAuthenticator,
|
||||
} from '../../ts_server/index.js';
|
||||
|
||||
// Integration test: full client-server communication
|
||||
let server: RadiusServer;
|
||||
const TEST_SECRET = 'integration-secret';
|
||||
const TEST_AUTH_PORT = 18200;
|
||||
const TEST_ACCT_PORT = 18210;
|
||||
|
||||
// Track accounting records
|
||||
const accountingRecords: any[] = [];
|
||||
|
||||
// User database
|
||||
const users: Record<string, { password: string; sessionTimeout?: number }> = {
|
||||
alice: { password: 'alice123', sessionTimeout: 7200 },
|
||||
bob: { password: 'bob456', sessionTimeout: 3600 },
|
||||
charlie: { password: 'charlie789' },
|
||||
};
|
||||
|
||||
tap.test('setup - start integration test server', async () => {
|
||||
server = new RadiusServer({
|
||||
authPort: TEST_AUTH_PORT,
|
||||
acctPort: TEST_ACCT_PORT,
|
||||
defaultSecret: TEST_SECRET,
|
||||
authenticationHandler: async (request) => {
|
||||
const user = users[request.username];
|
||||
if (!user) {
|
||||
return {
|
||||
code: ERadiusCode.AccessReject,
|
||||
replyMessage: 'Unknown user',
|
||||
};
|
||||
}
|
||||
|
||||
// PAP authentication
|
||||
if (request.password !== undefined) {
|
||||
if (request.password === user.password) {
|
||||
return {
|
||||
code: ERadiusCode.AccessAccept,
|
||||
replyMessage: `Welcome, ${request.username}!`,
|
||||
sessionTimeout: user.sessionTimeout,
|
||||
framedIpAddress: '10.0.0.' + (Object.keys(users).indexOf(request.username) + 10),
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: ERadiusCode.AccessReject,
|
||||
replyMessage: 'Invalid password',
|
||||
};
|
||||
}
|
||||
|
||||
// CHAP authentication
|
||||
if (request.chapPassword && request.chapChallenge) {
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(
|
||||
request.chapPassword,
|
||||
request.chapChallenge,
|
||||
user.password
|
||||
);
|
||||
if (isValid) {
|
||||
return {
|
||||
code: ERadiusCode.AccessAccept,
|
||||
replyMessage: `Welcome, ${request.username}! (CHAP)`,
|
||||
sessionTimeout: user.sessionTimeout,
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: ERadiusCode.AccessReject,
|
||||
replyMessage: 'CHAP authentication failed',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: ERadiusCode.AccessReject,
|
||||
replyMessage: 'No authentication method provided',
|
||||
};
|
||||
},
|
||||
accountingHandler: async (request) => {
|
||||
accountingRecords.push({
|
||||
statusType: request.statusType,
|
||||
sessionId: request.sessionId,
|
||||
username: request.username,
|
||||
sessionTime: request.sessionTime,
|
||||
inputOctets: request.inputOctets,
|
||||
outputOctets: request.outputOctets,
|
||||
terminateCause: request.terminateCause,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
await server.start();
|
||||
});
|
||||
|
||||
tap.test('integration: PAP authentication for multiple users', async () => {
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: TEST_AUTH_PORT,
|
||||
acctPort: TEST_ACCT_PORT,
|
||||
secret: TEST_SECRET,
|
||||
timeout: 2000,
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
// Alice
|
||||
let response = await client.authenticatePap('alice', 'alice123');
|
||||
expect(response.accepted).toBeTruthy();
|
||||
expect(response.replyMessage).toInclude('alice');
|
||||
expect(response.sessionTimeout).toEqual(7200);
|
||||
expect(response.framedIpAddress).toEqual('10.0.0.10');
|
||||
|
||||
// Bob
|
||||
response = await client.authenticatePap('bob', 'bob456');
|
||||
expect(response.accepted).toBeTruthy();
|
||||
expect(response.replyMessage).toInclude('bob');
|
||||
expect(response.sessionTimeout).toEqual(3600);
|
||||
|
||||
// Charlie (no session timeout)
|
||||
response = await client.authenticatePap('charlie', 'charlie789');
|
||||
expect(response.accepted).toBeTruthy();
|
||||
expect(response.replyMessage).toInclude('charlie');
|
||||
expect(response.sessionTimeout).toBeUndefined();
|
||||
|
||||
// Unknown user
|
||||
response = await client.authenticatePap('unknown', 'password');
|
||||
expect(response.rejected).toBeTruthy();
|
||||
expect(response.replyMessage).toEqual('Unknown user');
|
||||
|
||||
// Wrong password
|
||||
response = await client.authenticatePap('alice', 'wrongpassword');
|
||||
expect(response.rejected).toBeTruthy();
|
||||
expect(response.replyMessage).toEqual('Invalid password');
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
tap.test('integration: CHAP authentication', async () => {
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: TEST_AUTH_PORT,
|
||||
acctPort: TEST_ACCT_PORT,
|
||||
secret: TEST_SECRET,
|
||||
timeout: 2000,
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
// CHAP with correct password
|
||||
let response = await client.authenticateChap('bob', 'bob456');
|
||||
expect(response.accepted).toBeTruthy();
|
||||
expect(response.replyMessage).toInclude('CHAP');
|
||||
|
||||
// CHAP with wrong password
|
||||
response = await client.authenticateChap('bob', 'wrongpass');
|
||||
expect(response.rejected).toBeTruthy();
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
tap.test('integration: full session lifecycle with accounting', async () => {
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: TEST_AUTH_PORT,
|
||||
acctPort: TEST_ACCT_PORT,
|
||||
secret: TEST_SECRET,
|
||||
timeout: 2000,
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
const sessionId = `session-${Date.now()}`;
|
||||
|
||||
// 1. Authenticate
|
||||
const authResponse = await client.authenticatePap('alice', 'alice123');
|
||||
expect(authResponse.accepted).toBeTruthy();
|
||||
|
||||
// 2. Accounting Start
|
||||
let acctResponse = await client.accountingStart(sessionId, 'alice');
|
||||
expect(acctResponse.success).toBeTruthy();
|
||||
|
||||
// 3. Interim Update
|
||||
acctResponse = await client.accountingUpdate(sessionId, {
|
||||
username: 'alice',
|
||||
sessionTime: 300,
|
||||
inputOctets: 100000,
|
||||
outputOctets: 200000,
|
||||
});
|
||||
expect(acctResponse.success).toBeTruthy();
|
||||
|
||||
// 4. Accounting Stop
|
||||
acctResponse = await client.accountingStop(sessionId, {
|
||||
username: 'alice',
|
||||
sessionTime: 600,
|
||||
inputOctets: 250000,
|
||||
outputOctets: 500000,
|
||||
terminateCause: EAcctTerminateCause.UserRequest,
|
||||
});
|
||||
expect(acctResponse.success).toBeTruthy();
|
||||
|
||||
// Verify accounting records
|
||||
const sessionRecords = accountingRecords.filter((r) => r.sessionId === sessionId);
|
||||
expect(sessionRecords.length).toEqual(3);
|
||||
|
||||
const startRecord = sessionRecords.find((r) => r.statusType === EAcctStatusType.Start);
|
||||
expect(startRecord).toBeDefined();
|
||||
expect(startRecord!.username).toEqual('alice');
|
||||
|
||||
const updateRecord = sessionRecords.find((r) => r.statusType === EAcctStatusType.InterimUpdate);
|
||||
expect(updateRecord).toBeDefined();
|
||||
expect(updateRecord!.sessionTime).toEqual(300);
|
||||
|
||||
const stopRecord = sessionRecords.find((r) => r.statusType === EAcctStatusType.Stop);
|
||||
expect(stopRecord).toBeDefined();
|
||||
expect(stopRecord!.sessionTime).toEqual(600);
|
||||
expect(stopRecord!.terminateCause).toEqual(EAcctTerminateCause.UserRequest);
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
tap.test('integration: multiple concurrent clients', async () => {
|
||||
const clients = [];
|
||||
const results = [];
|
||||
|
||||
// Create 5 clients
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: TEST_AUTH_PORT,
|
||||
acctPort: TEST_ACCT_PORT,
|
||||
secret: TEST_SECRET,
|
||||
timeout: 2000,
|
||||
});
|
||||
await client.connect();
|
||||
clients.push(client);
|
||||
}
|
||||
|
||||
// Send authentication requests concurrently
|
||||
const promises = clients.map((client, i) =>
|
||||
client.authenticatePap(i % 2 === 0 ? 'alice' : 'bob', i % 2 === 0 ? 'alice123' : 'bob456')
|
||||
);
|
||||
|
||||
const responses = await Promise.all(promises);
|
||||
|
||||
// All should succeed
|
||||
for (const response of responses) {
|
||||
expect(response.accepted).toBeTruthy();
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
for (const client of clients) {
|
||||
await client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('integration: server statistics', async () => {
|
||||
const stats = server.getStats();
|
||||
|
||||
// Should have recorded some requests
|
||||
expect(stats.authRequests).toBeGreaterThan(0);
|
||||
expect(stats.authAccepts).toBeGreaterThan(0);
|
||||
expect(stats.authRejects).toBeGreaterThan(0);
|
||||
expect(stats.acctRequests).toBeGreaterThan(0);
|
||||
expect(stats.acctResponses).toBeGreaterThan(0);
|
||||
|
||||
// Should have no invalid packets (we sent valid ones)
|
||||
expect(stats.authInvalidPackets).toEqual(0);
|
||||
expect(stats.acctInvalidPackets).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('integration: duplicate request handling', async () => {
|
||||
// This tests that the server handles duplicate requests correctly
|
||||
// by caching responses within the detection window
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: TEST_AUTH_PORT,
|
||||
acctPort: TEST_ACCT_PORT,
|
||||
secret: TEST_SECRET,
|
||||
timeout: 2000,
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
// Send same request multiple times quickly
|
||||
const responses = await Promise.all([
|
||||
client.authenticatePap('alice', 'alice123'),
|
||||
client.authenticatePap('alice', 'alice123'),
|
||||
client.authenticatePap('alice', 'alice123'),
|
||||
]);
|
||||
|
||||
// All should succeed with same result
|
||||
for (const response of responses) {
|
||||
expect(response.accepted).toBeTruthy();
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
tap.test('teardown - stop integration test server', async () => {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
149
test/client/test.timeout.ts
Normal file
149
test/client/test.timeout.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { RadiusClient } from '../../ts_client/index.js';
|
||||
|
||||
tap.test('should timeout when server is not reachable', async () => {
|
||||
// Connect to a port where no server is running
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: 19999, // Unlikely to have a server
|
||||
acctPort: 19998,
|
||||
secret: 'testing123',
|
||||
timeout: 500, // Short timeout
|
||||
retries: 1, // Minimal retries
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
await client.authenticatePap('user', 'pass');
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
|
||||
expect(error).toBeDefined();
|
||||
expect(error!.message).toInclude('timed out');
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
tap.test('should retry on timeout', async () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: 19997,
|
||||
acctPort: 19996,
|
||||
secret: 'testing123',
|
||||
timeout: 200, // 200ms timeout
|
||||
retries: 3, // 3 retries
|
||||
retryDelay: 100, // 100ms initial delay
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
await client.authenticatePap('user', 'pass');
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
expect(error).toBeDefined();
|
||||
// With 3 retries and exponential backoff, should take at least:
|
||||
// Initial timeout (200) + retry 1 delay (100) + timeout (200) + retry 2 delay (200) + timeout (200) + retry 3 delay (400) + timeout (200)
|
||||
// = 200 + 100 + 200 + 200 + 200 + 400 + 200 = ~1500ms minimum
|
||||
expect(elapsed).toBeGreaterThanOrEqual(500);
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
tap.test('should handle disconnect during request', async () => {
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: 19995,
|
||||
acctPort: 19994,
|
||||
secret: 'testing123',
|
||||
timeout: 5000,
|
||||
retries: 3,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
// Start a request (will never complete because no server)
|
||||
const requestPromise = client.authenticatePap('user', 'pass');
|
||||
|
||||
// Disconnect immediately
|
||||
await client.disconnect();
|
||||
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
await requestPromise;
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
|
||||
// When the client disconnects, pending requests are rejected
|
||||
// The error can be either "Client disconnected" or other disconnect-related messages
|
||||
expect(error).toBeDefined();
|
||||
// Just verify we got an error - the specific message may vary
|
||||
});
|
||||
|
||||
tap.test('should handle multiple concurrent requests', async () => {
|
||||
// This test just verifies we can make multiple requests
|
||||
// They will all timeout since no server, but should handle correctly
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: 19993,
|
||||
acctPort: 19992,
|
||||
secret: 'testing123',
|
||||
timeout: 200,
|
||||
retries: 1,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
const requests = [
|
||||
client.authenticatePap('user1', 'pass1').catch((e) => e),
|
||||
client.authenticatePap('user2', 'pass2').catch((e) => e),
|
||||
client.authenticatePap('user3', 'pass3').catch((e) => e),
|
||||
];
|
||||
|
||||
const results = await Promise.all(requests);
|
||||
|
||||
// All should be errors (timeout)
|
||||
for (const result of results) {
|
||||
expect(result).toBeInstanceOf(Error);
|
||||
}
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
tap.test('should auto-connect if not connected', async () => {
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
authPort: 19991,
|
||||
acctPort: 19990,
|
||||
secret: 'testing123',
|
||||
timeout: 200,
|
||||
retries: 1,
|
||||
});
|
||||
|
||||
// Don't call connect() - should auto-connect
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
await client.authenticatePap('user', 'pass');
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
|
||||
// Should timeout, not connection error
|
||||
expect(error).toBeDefined();
|
||||
expect(error!.message).toInclude('timed out');
|
||||
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
246
test/server/test.accounting.ts
Normal file
246
test/server/test.accounting.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
RadiusPacket,
|
||||
RadiusAuthenticator,
|
||||
ERadiusCode,
|
||||
ERadiusAttributeType,
|
||||
EAcctStatusType,
|
||||
EAcctTerminateCause,
|
||||
EAcctAuthentic,
|
||||
} from '../../ts_server/index.js';
|
||||
|
||||
tap.test('should create Accounting-Request packet with Start status', async () => {
|
||||
const identifier = 1;
|
||||
const secret = 'testing123';
|
||||
|
||||
const packet = RadiusPacket.createAccountingRequest(identifier, secret, [
|
||||
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.Start },
|
||||
{ type: ERadiusAttributeType.AcctSessionId, value: 'session-001' },
|
||||
{ type: ERadiusAttributeType.UserName, value: 'testuser' },
|
||||
{ type: ERadiusAttributeType.NasIpAddress, value: '192.168.1.1' },
|
||||
]);
|
||||
|
||||
const decoded = RadiusPacket.decode(packet);
|
||||
expect(decoded.code).toEqual(ERadiusCode.AccountingRequest);
|
||||
expect(decoded.identifier).toEqual(identifier);
|
||||
});
|
||||
|
||||
tap.test('should create Accounting-Request packet with Stop status', async () => {
|
||||
const identifier = 2;
|
||||
const secret = 'testing123';
|
||||
|
||||
const packet = RadiusPacket.createAccountingRequest(identifier, secret, [
|
||||
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.Stop },
|
||||
{ type: ERadiusAttributeType.AcctSessionId, value: 'session-001' },
|
||||
{ type: ERadiusAttributeType.UserName, value: 'testuser' },
|
||||
{ type: ERadiusAttributeType.AcctSessionTime, value: 3600 }, // 1 hour
|
||||
{ type: ERadiusAttributeType.AcctInputOctets, value: 1024000 },
|
||||
{ type: ERadiusAttributeType.AcctOutputOctets, value: 2048000 },
|
||||
{ type: ERadiusAttributeType.AcctInputPackets, value: 1000 },
|
||||
{ type: ERadiusAttributeType.AcctOutputPackets, value: 2000 },
|
||||
{ type: ERadiusAttributeType.AcctTerminateCause, value: EAcctTerminateCause.UserRequest },
|
||||
]);
|
||||
|
||||
const decoded = RadiusPacket.decodeAndParse(packet);
|
||||
expect(decoded.code).toEqual(ERadiusCode.AccountingRequest);
|
||||
|
||||
const statusType = decoded.parsedAttributes.find(
|
||||
(a) => a.type === ERadiusAttributeType.AcctStatusType
|
||||
);
|
||||
expect(statusType?.value).toEqual(EAcctStatusType.Stop);
|
||||
});
|
||||
|
||||
tap.test('should create Accounting-Request packet with Interim-Update status', async () => {
|
||||
const identifier = 3;
|
||||
const secret = 'testing123';
|
||||
|
||||
const packet = RadiusPacket.createAccountingRequest(identifier, secret, [
|
||||
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.InterimUpdate },
|
||||
{ type: ERadiusAttributeType.AcctSessionId, value: 'session-001' },
|
||||
{ type: ERadiusAttributeType.UserName, value: 'testuser' },
|
||||
{ type: ERadiusAttributeType.AcctSessionTime, value: 1800 }, // 30 min
|
||||
{ type: ERadiusAttributeType.AcctInputOctets, value: 512000 },
|
||||
{ type: ERadiusAttributeType.AcctOutputOctets, value: 1024000 },
|
||||
]);
|
||||
|
||||
const decoded = RadiusPacket.decodeAndParse(packet);
|
||||
expect(decoded.code).toEqual(ERadiusCode.AccountingRequest);
|
||||
|
||||
const statusType = decoded.parsedAttributes.find(
|
||||
(a) => a.type === ERadiusAttributeType.AcctStatusType
|
||||
);
|
||||
expect(statusType?.value).toEqual(EAcctStatusType.InterimUpdate);
|
||||
});
|
||||
|
||||
tap.test('should verify Accounting-Request authenticator', async () => {
|
||||
const identifier = 1;
|
||||
const secret = 'testing123';
|
||||
|
||||
const packet = RadiusPacket.createAccountingRequest(identifier, secret, [
|
||||
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.Start },
|
||||
{ type: ERadiusAttributeType.AcctSessionId, value: 'session-001' },
|
||||
]);
|
||||
|
||||
// Verify the authenticator
|
||||
const isValid = RadiusAuthenticator.verifyAccountingRequestAuthenticator(packet, secret);
|
||||
expect(isValid).toBeTruthy();
|
||||
|
||||
// Should fail with wrong secret
|
||||
const isInvalid = RadiusAuthenticator.verifyAccountingRequestAuthenticator(packet, 'wrongsecret');
|
||||
expect(isInvalid).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should create Accounting-Response packet', async () => {
|
||||
const identifier = 1;
|
||||
const requestAuthenticator = Buffer.alloc(16, 0x42);
|
||||
const secret = 'testing123';
|
||||
|
||||
const packet = RadiusPacket.createAccountingResponse(
|
||||
identifier,
|
||||
requestAuthenticator,
|
||||
secret,
|
||||
[] // Usually no attributes in response
|
||||
);
|
||||
|
||||
const decoded = RadiusPacket.decode(packet);
|
||||
expect(decoded.code).toEqual(ERadiusCode.AccountingResponse);
|
||||
expect(decoded.identifier).toEqual(identifier);
|
||||
});
|
||||
|
||||
tap.test('should verify Accounting-Response authenticator', async () => {
|
||||
const identifier = 1;
|
||||
const requestAuthenticator = Buffer.alloc(16, 0x42);
|
||||
const secret = 'testing123';
|
||||
|
||||
const response = RadiusPacket.createAccountingResponse(
|
||||
identifier,
|
||||
requestAuthenticator,
|
||||
secret,
|
||||
[]
|
||||
);
|
||||
|
||||
// Verify response authenticator
|
||||
const isValid = RadiusAuthenticator.verifyResponseAuthenticator(
|
||||
response,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should parse all accounting attributes correctly', async () => {
|
||||
const identifier = 1;
|
||||
const secret = 'testing123';
|
||||
|
||||
const packet = RadiusPacket.createAccountingRequest(identifier, secret, [
|
||||
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.Stop },
|
||||
{ type: ERadiusAttributeType.AcctDelayTime, value: 5 },
|
||||
{ type: ERadiusAttributeType.AcctInputOctets, value: 1000000 },
|
||||
{ type: ERadiusAttributeType.AcctOutputOctets, value: 2000000 },
|
||||
{ type: ERadiusAttributeType.AcctSessionId, value: 'sess-123' },
|
||||
{ type: ERadiusAttributeType.AcctAuthentic, value: EAcctAuthentic.Radius },
|
||||
{ type: ERadiusAttributeType.AcctSessionTime, value: 7200 },
|
||||
{ type: ERadiusAttributeType.AcctInputPackets, value: 5000 },
|
||||
{ type: ERadiusAttributeType.AcctOutputPackets, value: 10000 },
|
||||
{ type: ERadiusAttributeType.AcctTerminateCause, value: EAcctTerminateCause.SessionTimeout },
|
||||
{ type: ERadiusAttributeType.AcctMultiSessionId, value: 'multi-sess-456' },
|
||||
{ type: ERadiusAttributeType.AcctLinkCount, value: 2 },
|
||||
]);
|
||||
|
||||
const decoded = RadiusPacket.decodeAndParse(packet);
|
||||
|
||||
// Check each attribute
|
||||
const attrs = decoded.parsedAttributes;
|
||||
|
||||
const statusType = attrs.find((a) => a.type === ERadiusAttributeType.AcctStatusType);
|
||||
expect(statusType?.value).toEqual(EAcctStatusType.Stop);
|
||||
|
||||
const delayTime = attrs.find((a) => a.type === ERadiusAttributeType.AcctDelayTime);
|
||||
expect(delayTime?.value).toEqual(5);
|
||||
|
||||
const inputOctets = attrs.find((a) => a.type === ERadiusAttributeType.AcctInputOctets);
|
||||
expect(inputOctets?.value).toEqual(1000000);
|
||||
|
||||
const outputOctets = attrs.find((a) => a.type === ERadiusAttributeType.AcctOutputOctets);
|
||||
expect(outputOctets?.value).toEqual(2000000);
|
||||
|
||||
const sessionId = attrs.find((a) => a.type === ERadiusAttributeType.AcctSessionId);
|
||||
expect(sessionId?.value).toEqual('sess-123');
|
||||
|
||||
const authentic = attrs.find((a) => a.type === ERadiusAttributeType.AcctAuthentic);
|
||||
expect(authentic?.value).toEqual(EAcctAuthentic.Radius);
|
||||
|
||||
const sessionTime = attrs.find((a) => a.type === ERadiusAttributeType.AcctSessionTime);
|
||||
expect(sessionTime?.value).toEqual(7200);
|
||||
|
||||
const terminateCause = attrs.find((a) => a.type === ERadiusAttributeType.AcctTerminateCause);
|
||||
expect(terminateCause?.value).toEqual(EAcctTerminateCause.SessionTimeout);
|
||||
});
|
||||
|
||||
tap.test('should handle Accounting-On/Off status types', async () => {
|
||||
const secret = 'testing123';
|
||||
|
||||
// Accounting-On (NAS restart/reboot notification)
|
||||
const acctOnPacket = RadiusPacket.createAccountingRequest(1, secret, [
|
||||
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.AccountingOn },
|
||||
{ type: ERadiusAttributeType.NasIpAddress, value: '192.168.1.1' },
|
||||
]);
|
||||
|
||||
let decoded = RadiusPacket.decodeAndParse(acctOnPacket);
|
||||
let statusType = decoded.parsedAttributes.find(
|
||||
(a) => a.type === ERadiusAttributeType.AcctStatusType
|
||||
);
|
||||
expect(statusType?.value).toEqual(EAcctStatusType.AccountingOn);
|
||||
|
||||
// Accounting-Off
|
||||
const acctOffPacket = RadiusPacket.createAccountingRequest(2, secret, [
|
||||
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.AccountingOff },
|
||||
{ type: ERadiusAttributeType.NasIpAddress, value: '192.168.1.1' },
|
||||
]);
|
||||
|
||||
decoded = RadiusPacket.decodeAndParse(acctOffPacket);
|
||||
statusType = decoded.parsedAttributes.find(
|
||||
(a) => a.type === ERadiusAttributeType.AcctStatusType
|
||||
);
|
||||
expect(statusType?.value).toEqual(EAcctStatusType.AccountingOff);
|
||||
});
|
||||
|
||||
tap.test('should handle all termination causes', async () => {
|
||||
const secret = 'testing123';
|
||||
const terminationCauses = [
|
||||
EAcctTerminateCause.UserRequest,
|
||||
EAcctTerminateCause.LostCarrier,
|
||||
EAcctTerminateCause.LostService,
|
||||
EAcctTerminateCause.IdleTimeout,
|
||||
EAcctTerminateCause.SessionTimeout,
|
||||
EAcctTerminateCause.AdminReset,
|
||||
EAcctTerminateCause.AdminReboot,
|
||||
EAcctTerminateCause.PortError,
|
||||
EAcctTerminateCause.NasError,
|
||||
EAcctTerminateCause.NasRequest,
|
||||
EAcctTerminateCause.NasReboot,
|
||||
EAcctTerminateCause.PortUnneeded,
|
||||
EAcctTerminateCause.PortPreempted,
|
||||
EAcctTerminateCause.PortSuspended,
|
||||
EAcctTerminateCause.ServiceUnavailable,
|
||||
EAcctTerminateCause.Callback,
|
||||
EAcctTerminateCause.UserError,
|
||||
EAcctTerminateCause.HostRequest,
|
||||
];
|
||||
|
||||
for (const cause of terminationCauses) {
|
||||
const packet = RadiusPacket.createAccountingRequest(1, secret, [
|
||||
{ type: ERadiusAttributeType.AcctStatusType, value: EAcctStatusType.Stop },
|
||||
{ type: ERadiusAttributeType.AcctSessionId, value: 'session-001' },
|
||||
{ type: ERadiusAttributeType.AcctTerminateCause, value: cause },
|
||||
]);
|
||||
|
||||
const decoded = RadiusPacket.decodeAndParse(packet);
|
||||
const termCause = decoded.parsedAttributes.find(
|
||||
(a) => a.type === ERadiusAttributeType.AcctTerminateCause
|
||||
);
|
||||
expect(termCause?.value).toEqual(cause);
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
211
test/server/test.attributes.ts
Normal file
211
test/server/test.attributes.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
RadiusAttributes,
|
||||
ERadiusAttributeType,
|
||||
} from '../../ts_server/index.js';
|
||||
|
||||
tap.test('should get attribute definitions by type', async () => {
|
||||
const userNameDef = RadiusAttributes.getDefinition(ERadiusAttributeType.UserName);
|
||||
expect(userNameDef).toBeDefined();
|
||||
expect(userNameDef!.name).toEqual('User-Name');
|
||||
expect(userNameDef!.valueType).toEqual('text');
|
||||
|
||||
const nasIpDef = RadiusAttributes.getDefinition(ERadiusAttributeType.NasIpAddress);
|
||||
expect(nasIpDef).toBeDefined();
|
||||
expect(nasIpDef!.name).toEqual('NAS-IP-Address');
|
||||
expect(nasIpDef!.valueType).toEqual('address');
|
||||
|
||||
const nasPortDef = RadiusAttributes.getDefinition(ERadiusAttributeType.NasPort);
|
||||
expect(nasPortDef).toBeDefined();
|
||||
expect(nasPortDef!.name).toEqual('NAS-Port');
|
||||
expect(nasPortDef!.valueType).toEqual('integer');
|
||||
});
|
||||
|
||||
tap.test('should get attribute type by name', async () => {
|
||||
expect(RadiusAttributes.getTypeByName('User-Name')).toEqual(ERadiusAttributeType.UserName);
|
||||
expect(RadiusAttributes.getTypeByName('NAS-IP-Address')).toEqual(ERadiusAttributeType.NasIpAddress);
|
||||
expect(RadiusAttributes.getTypeByName('NAS-Port')).toEqual(ERadiusAttributeType.NasPort);
|
||||
expect(RadiusAttributes.getTypeByName('Unknown-Attribute')).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should get attribute name by type', async () => {
|
||||
expect(RadiusAttributes.getNameByType(ERadiusAttributeType.UserName)).toEqual('User-Name');
|
||||
expect(RadiusAttributes.getNameByType(ERadiusAttributeType.UserPassword)).toEqual('User-Password');
|
||||
expect(RadiusAttributes.getNameByType(255)).toInclude('Unknown-Attribute');
|
||||
});
|
||||
|
||||
tap.test('should parse text attributes', async () => {
|
||||
const textBuffer = Buffer.from('testuser', 'utf8');
|
||||
const parsed = RadiusAttributes.parseValue(ERadiusAttributeType.UserName, textBuffer);
|
||||
expect(parsed).toEqual('testuser');
|
||||
});
|
||||
|
||||
tap.test('should parse address attributes', async () => {
|
||||
const addressBuffer = Buffer.from([192, 168, 1, 100]);
|
||||
const parsed = RadiusAttributes.parseValue(ERadiusAttributeType.NasIpAddress, addressBuffer);
|
||||
expect(parsed).toEqual('192.168.1.100');
|
||||
});
|
||||
|
||||
tap.test('should parse integer attributes', async () => {
|
||||
const intBuffer = Buffer.allocUnsafe(4);
|
||||
intBuffer.writeUInt32BE(12345, 0);
|
||||
const parsed = RadiusAttributes.parseValue(ERadiusAttributeType.NasPort, intBuffer);
|
||||
expect(parsed).toEqual(12345);
|
||||
});
|
||||
|
||||
tap.test('should encode text attributes', async () => {
|
||||
const encoded = RadiusAttributes.encodeValue(ERadiusAttributeType.UserName, 'testuser');
|
||||
expect(encoded.toString('utf8')).toEqual('testuser');
|
||||
});
|
||||
|
||||
tap.test('should encode address attributes', async () => {
|
||||
const encoded = RadiusAttributes.encodeValue(ERadiusAttributeType.NasIpAddress, '10.20.30.40');
|
||||
expect(encoded.length).toEqual(4);
|
||||
expect(encoded[0]).toEqual(10);
|
||||
expect(encoded[1]).toEqual(20);
|
||||
expect(encoded[2]).toEqual(30);
|
||||
expect(encoded[3]).toEqual(40);
|
||||
});
|
||||
|
||||
tap.test('should encode integer attributes', async () => {
|
||||
const encoded = RadiusAttributes.encodeValue(ERadiusAttributeType.NasPort, 65535);
|
||||
expect(encoded.length).toEqual(4);
|
||||
expect(encoded.readUInt32BE(0)).toEqual(65535);
|
||||
});
|
||||
|
||||
tap.test('should encode complete attribute with TLV format', async () => {
|
||||
const encoded = RadiusAttributes.encodeAttribute(ERadiusAttributeType.UserName, 'test');
|
||||
expect(encoded[0]).toEqual(ERadiusAttributeType.UserName); // Type
|
||||
expect(encoded[1]).toEqual(6); // Length (2 + 4)
|
||||
expect(encoded.subarray(2).toString('utf8')).toEqual('test');
|
||||
});
|
||||
|
||||
tap.test('should handle attribute value too long', async () => {
|
||||
const longValue = Buffer.alloc(254); // Max is 253 bytes
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
RadiusAttributes.encodeAttribute(ERadiusAttributeType.UserName, longValue);
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
expect(error).toBeDefined();
|
||||
expect(error!.message).toInclude('too long');
|
||||
});
|
||||
|
||||
tap.test('should parse raw attribute', async () => {
|
||||
const rawAttr = {
|
||||
type: ERadiusAttributeType.UserName,
|
||||
value: Buffer.from('john.doe', 'utf8'),
|
||||
};
|
||||
const parsed = RadiusAttributes.parseAttribute(rawAttr);
|
||||
expect(parsed.type).toEqual(ERadiusAttributeType.UserName);
|
||||
expect(parsed.name).toEqual('User-Name');
|
||||
expect(parsed.value).toEqual('john.doe');
|
||||
});
|
||||
|
||||
tap.test('should handle Vendor-Specific Attributes', async () => {
|
||||
// Vendor-Id: 9 (Cisco), Vendor-Type: 1, Value: 'test'
|
||||
const vendorId = 9;
|
||||
const vendorType = 1;
|
||||
const vendorValue = Buffer.from('cisco-av-pair', 'utf8');
|
||||
|
||||
const vsaBuffer = RadiusAttributes.encodeVSA({ vendorId, vendorType, vendorValue });
|
||||
|
||||
// Parse it back
|
||||
const parsed = RadiusAttributes.parseVSA(vsaBuffer);
|
||||
expect(parsed).toBeDefined();
|
||||
expect(parsed!.vendorId).toEqual(vendorId);
|
||||
expect(parsed!.vendorType).toEqual(vendorType);
|
||||
expect(parsed!.vendorValue.toString('utf8')).toEqual('cisco-av-pair');
|
||||
});
|
||||
|
||||
tap.test('should create complete Vendor-Specific attribute', async () => {
|
||||
const vendorId = 311; // Microsoft
|
||||
const vendorType = 1;
|
||||
const vendorValue = Buffer.from('test-value', 'utf8');
|
||||
|
||||
const attr = RadiusAttributes.createVendorAttribute(vendorId, vendorType, vendorValue);
|
||||
|
||||
// First byte should be type 26 (Vendor-Specific)
|
||||
expect(attr[0]).toEqual(ERadiusAttributeType.VendorSpecific);
|
||||
|
||||
// Second byte is total length
|
||||
expect(attr[1]).toBeGreaterThan(6);
|
||||
});
|
||||
|
||||
tap.test('should check if attribute is encrypted', async () => {
|
||||
expect(RadiusAttributes.isEncrypted(ERadiusAttributeType.UserPassword)).toBeTruthy();
|
||||
expect(RadiusAttributes.isEncrypted(ERadiusAttributeType.UserName)).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should handle all standard RFC 2865 attributes', async () => {
|
||||
// Test that all standard attributes have definitions
|
||||
const standardAttributes = [
|
||||
ERadiusAttributeType.UserName,
|
||||
ERadiusAttributeType.UserPassword,
|
||||
ERadiusAttributeType.ChapPassword,
|
||||
ERadiusAttributeType.NasIpAddress,
|
||||
ERadiusAttributeType.NasPort,
|
||||
ERadiusAttributeType.ServiceType,
|
||||
ERadiusAttributeType.FramedProtocol,
|
||||
ERadiusAttributeType.FramedIpAddress,
|
||||
ERadiusAttributeType.FramedIpNetmask,
|
||||
ERadiusAttributeType.FramedRouting,
|
||||
ERadiusAttributeType.FilterId,
|
||||
ERadiusAttributeType.FramedMtu,
|
||||
ERadiusAttributeType.FramedCompression,
|
||||
ERadiusAttributeType.LoginIpHost,
|
||||
ERadiusAttributeType.LoginService,
|
||||
ERadiusAttributeType.LoginTcpPort,
|
||||
ERadiusAttributeType.ReplyMessage,
|
||||
ERadiusAttributeType.CallbackNumber,
|
||||
ERadiusAttributeType.CallbackId,
|
||||
ERadiusAttributeType.FramedRoute,
|
||||
ERadiusAttributeType.FramedIpxNetwork,
|
||||
ERadiusAttributeType.State,
|
||||
ERadiusAttributeType.Class,
|
||||
ERadiusAttributeType.VendorSpecific,
|
||||
ERadiusAttributeType.SessionTimeout,
|
||||
ERadiusAttributeType.IdleTimeout,
|
||||
ERadiusAttributeType.TerminationAction,
|
||||
ERadiusAttributeType.CalledStationId,
|
||||
ERadiusAttributeType.CallingStationId,
|
||||
ERadiusAttributeType.NasIdentifier,
|
||||
ERadiusAttributeType.ProxyState,
|
||||
ERadiusAttributeType.ChapChallenge,
|
||||
ERadiusAttributeType.NasPortType,
|
||||
ERadiusAttributeType.PortLimit,
|
||||
];
|
||||
|
||||
for (const attrType of standardAttributes) {
|
||||
const def = RadiusAttributes.getDefinition(attrType);
|
||||
expect(def).toBeDefined();
|
||||
expect(def!.name).toBeDefined();
|
||||
expect(def!.valueType).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should handle all RFC 2866 accounting attributes', async () => {
|
||||
const accountingAttributes = [
|
||||
ERadiusAttributeType.AcctStatusType,
|
||||
ERadiusAttributeType.AcctDelayTime,
|
||||
ERadiusAttributeType.AcctInputOctets,
|
||||
ERadiusAttributeType.AcctOutputOctets,
|
||||
ERadiusAttributeType.AcctSessionId,
|
||||
ERadiusAttributeType.AcctAuthentic,
|
||||
ERadiusAttributeType.AcctSessionTime,
|
||||
ERadiusAttributeType.AcctInputPackets,
|
||||
ERadiusAttributeType.AcctOutputPackets,
|
||||
ERadiusAttributeType.AcctTerminateCause,
|
||||
ERadiusAttributeType.AcctMultiSessionId,
|
||||
ERadiusAttributeType.AcctLinkCount,
|
||||
];
|
||||
|
||||
for (const attrType of accountingAttributes) {
|
||||
const def = RadiusAttributes.getDefinition(attrType);
|
||||
expect(def).toBeDefined();
|
||||
expect(def!.name).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
205
test/server/test.authenticator.ts
Normal file
205
test/server/test.authenticator.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as crypto from 'crypto';
|
||||
import {
|
||||
RadiusAuthenticator,
|
||||
RadiusPacket,
|
||||
ERadiusCode,
|
||||
ERadiusAttributeType,
|
||||
} from '../../ts_server/index.js';
|
||||
|
||||
tap.test('should generate 16-byte random request authenticator', async () => {
|
||||
const auth1 = RadiusAuthenticator.generateRequestAuthenticator();
|
||||
const auth2 = RadiusAuthenticator.generateRequestAuthenticator();
|
||||
|
||||
expect(auth1.length).toEqual(16);
|
||||
expect(auth2.length).toEqual(16);
|
||||
|
||||
// Should be random (different each time)
|
||||
expect(auth1.equals(auth2)).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should calculate response authenticator correctly', async () => {
|
||||
const code = ERadiusCode.AccessAccept;
|
||||
const identifier = 1;
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const attributes = Buffer.from([]);
|
||||
const secret = 'testing123';
|
||||
|
||||
const responseAuth = RadiusAuthenticator.calculateResponseAuthenticator(
|
||||
code,
|
||||
identifier,
|
||||
requestAuthenticator,
|
||||
attributes,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(responseAuth.length).toEqual(16);
|
||||
|
||||
// Verify by recalculating
|
||||
const verified = RadiusAuthenticator.calculateResponseAuthenticator(
|
||||
code,
|
||||
identifier,
|
||||
requestAuthenticator,
|
||||
attributes,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(responseAuth.equals(verified)).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should calculate accounting request authenticator', async () => {
|
||||
const code = ERadiusCode.AccountingRequest;
|
||||
const identifier = 1;
|
||||
const attributes = Buffer.from([]);
|
||||
const secret = 'testing123';
|
||||
|
||||
const acctAuth = RadiusAuthenticator.calculateAccountingRequestAuthenticator(
|
||||
code,
|
||||
identifier,
|
||||
attributes,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(acctAuth.length).toEqual(16);
|
||||
});
|
||||
|
||||
tap.test('should verify accounting request authenticator', async () => {
|
||||
const secret = 'testing123';
|
||||
|
||||
// Create an accounting request packet
|
||||
const packet = RadiusPacket.createAccountingRequest(1, secret, [
|
||||
{ type: ERadiusAttributeType.AcctStatusType, value: 1 }, // Start
|
||||
{ type: ERadiusAttributeType.AcctSessionId, value: 'session-123' },
|
||||
]);
|
||||
|
||||
// Verify the authenticator
|
||||
const isValid = RadiusAuthenticator.verifyAccountingRequestAuthenticator(packet, secret);
|
||||
expect(isValid).toBeTruthy();
|
||||
|
||||
// Should fail with wrong secret
|
||||
const isInvalid = RadiusAuthenticator.verifyAccountingRequestAuthenticator(packet, 'wrongsecret');
|
||||
expect(isInvalid).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should verify response authenticator', async () => {
|
||||
const identifier = 1;
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
// Create a response packet
|
||||
const responsePacket = RadiusPacket.createAccessAccept(
|
||||
identifier,
|
||||
requestAuthenticator,
|
||||
secret,
|
||||
[{ type: ERadiusAttributeType.ReplyMessage, value: 'Welcome' }]
|
||||
);
|
||||
|
||||
// Verify the authenticator
|
||||
const isValid = RadiusAuthenticator.verifyResponseAuthenticator(
|
||||
responsePacket,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
expect(isValid).toBeTruthy();
|
||||
|
||||
// Should fail with wrong request authenticator
|
||||
const wrongRequestAuth = crypto.randomBytes(16);
|
||||
const isInvalid = RadiusAuthenticator.verifyResponseAuthenticator(
|
||||
responsePacket,
|
||||
wrongRequestAuth,
|
||||
secret
|
||||
);
|
||||
expect(isInvalid).toBeFalsy();
|
||||
|
||||
// Should fail with wrong secret
|
||||
const isInvalid2 = RadiusAuthenticator.verifyResponseAuthenticator(
|
||||
responsePacket,
|
||||
requestAuthenticator,
|
||||
'wrongsecret'
|
||||
);
|
||||
expect(isInvalid2).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should create packet header', async () => {
|
||||
const code = ERadiusCode.AccessRequest;
|
||||
const identifier = 42;
|
||||
const authenticator = crypto.randomBytes(16);
|
||||
const attributesLength = 50;
|
||||
|
||||
const header = RadiusAuthenticator.createPacketHeader(
|
||||
code,
|
||||
identifier,
|
||||
authenticator,
|
||||
attributesLength
|
||||
);
|
||||
|
||||
expect(header.length).toEqual(20);
|
||||
expect(header[0]).toEqual(code);
|
||||
expect(header[1]).toEqual(identifier);
|
||||
expect(header.readUInt16BE(2)).toEqual(20 + attributesLength);
|
||||
expect(header.subarray(4, 20).equals(authenticator)).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should calculate CHAP response', async () => {
|
||||
const chapId = 1;
|
||||
const password = 'testpassword';
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
|
||||
|
||||
expect(response.length).toEqual(16);
|
||||
|
||||
// Verify the calculation: MD5(CHAP-ID + Password + Challenge)
|
||||
const md5 = crypto.createHash('md5');
|
||||
md5.update(Buffer.from([chapId]));
|
||||
md5.update(Buffer.from(password, 'utf8'));
|
||||
md5.update(challenge);
|
||||
const expected = md5.digest();
|
||||
|
||||
expect(response.equals(expected)).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should verify CHAP response', async () => {
|
||||
const chapId = 1;
|
||||
const password = 'testpassword';
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
// Calculate the response
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
|
||||
|
||||
// Create CHAP-Password attribute format: CHAP-ID (1 byte) + Response (16 bytes)
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(chapId, 0);
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
// Verify with correct password
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
|
||||
expect(isValid).toBeTruthy();
|
||||
|
||||
// Verify with wrong password
|
||||
const isInvalid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, 'wrongpassword');
|
||||
expect(isInvalid).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should calculate Message-Authenticator (HMAC-MD5)', async () => {
|
||||
const packet = Buffer.alloc(50);
|
||||
packet[0] = ERadiusCode.AccessRequest;
|
||||
packet[1] = 1;
|
||||
packet.writeUInt16BE(50, 2);
|
||||
crypto.randomBytes(16).copy(packet, 4);
|
||||
|
||||
const secret = 'testing123';
|
||||
|
||||
const msgAuth = RadiusAuthenticator.calculateMessageAuthenticator(packet, secret);
|
||||
|
||||
expect(msgAuth.length).toEqual(16);
|
||||
|
||||
// Verify it's HMAC-MD5
|
||||
const hmac = crypto.createHmac('md5', Buffer.from(secret, 'utf8'));
|
||||
hmac.update(packet);
|
||||
const expected = hmac.digest();
|
||||
|
||||
expect(msgAuth.equals(expected)).toBeTruthy();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
209
test/server/test.chap.ts
Normal file
209
test/server/test.chap.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as crypto from 'crypto';
|
||||
import { RadiusAuthenticator } from '../../ts_server/index.js';
|
||||
|
||||
tap.test('should calculate CHAP response per RFC', async () => {
|
||||
// CHAP-Response = MD5(CHAP-ID + Password + Challenge)
|
||||
const chapId = 42;
|
||||
const password = 'mypassword';
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
|
||||
|
||||
// Verify manually
|
||||
const md5 = crypto.createHash('md5');
|
||||
md5.update(Buffer.from([chapId]));
|
||||
md5.update(Buffer.from(password, 'utf8'));
|
||||
md5.update(challenge);
|
||||
const expected = md5.digest();
|
||||
|
||||
expect(response.length).toEqual(16);
|
||||
expect(response.equals(expected)).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should verify valid CHAP response', async () => {
|
||||
const chapId = 1;
|
||||
const password = 'correctpassword';
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
// Calculate the expected response
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
|
||||
|
||||
// Build CHAP-Password attribute: CHAP Ident (1 byte) + Response (16 bytes)
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(chapId, 0);
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
// Should verify with correct password
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should reject CHAP response with wrong password', async () => {
|
||||
const chapId = 1;
|
||||
const correctPassword = 'correctpassword';
|
||||
const wrongPassword = 'wrongpassword';
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
// Calculate response with correct password
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, correctPassword, challenge);
|
||||
|
||||
// Build CHAP-Password attribute
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(chapId, 0);
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
// Should NOT verify with wrong password
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, wrongPassword);
|
||||
expect(isValid).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should reject CHAP response with wrong challenge', async () => {
|
||||
const chapId = 1;
|
||||
const password = 'mypassword';
|
||||
const correctChallenge = crypto.randomBytes(16);
|
||||
const wrongChallenge = crypto.randomBytes(16);
|
||||
|
||||
// Calculate response with correct challenge
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, correctChallenge);
|
||||
|
||||
// Build CHAP-Password attribute
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(chapId, 0);
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
// Should NOT verify with wrong challenge
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, wrongChallenge, password);
|
||||
expect(isValid).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should reject CHAP response with wrong identifier', async () => {
|
||||
const correctChapId = 1;
|
||||
const wrongChapId = 2;
|
||||
const password = 'mypassword';
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
// Calculate response with correct CHAP ID
|
||||
const response = RadiusAuthenticator.calculateChapResponse(correctChapId, password, challenge);
|
||||
|
||||
// Build CHAP-Password with WRONG CHAP ID
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(wrongChapId, 0); // Wrong ID
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
// Should NOT verify
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
|
||||
expect(isValid).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should reject invalid CHAP-Password length', async () => {
|
||||
const challenge = crypto.randomBytes(16);
|
||||
const password = 'mypassword';
|
||||
|
||||
// CHAP-Password must be exactly 17 bytes (1 + 16)
|
||||
const invalidChapPassword = Buffer.alloc(16); // Too short
|
||||
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(invalidChapPassword, challenge, password);
|
||||
expect(isValid).toBeFalsy();
|
||||
|
||||
const tooLongChapPassword = Buffer.alloc(18); // Too long
|
||||
const isValid2 = RadiusAuthenticator.verifyChapResponse(tooLongChapPassword, challenge, password);
|
||||
expect(isValid2).toBeFalsy();
|
||||
});
|
||||
|
||||
tap.test('should handle all CHAP ID values (0-255)', async () => {
|
||||
const password = 'testpassword';
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
for (const chapId of [0, 1, 127, 128, 254, 255]) {
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
|
||||
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(chapId, 0);
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
|
||||
expect(isValid).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should handle special characters in password', async () => {
|
||||
const chapId = 1;
|
||||
const password = '!@#$%^&*()_+-=[]{}|;:,.<>?~`';
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
|
||||
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(chapId, 0);
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should handle unicode password', async () => {
|
||||
const chapId = 1;
|
||||
const password = '密码パスワード'; // Chinese + Japanese
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
|
||||
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(chapId, 0);
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should handle empty password', async () => {
|
||||
const chapId = 1;
|
||||
const password = '';
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
|
||||
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(chapId, 0);
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should handle very long password', async () => {
|
||||
const chapId = 1;
|
||||
const password = 'a'.repeat(1000); // Very long password
|
||||
const challenge = crypto.randomBytes(16);
|
||||
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
|
||||
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(chapId, 0);
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should handle different challenge lengths', async () => {
|
||||
const chapId = 1;
|
||||
const password = 'testpassword';
|
||||
|
||||
// CHAP challenge can be various lengths
|
||||
for (const length of [8, 16, 24, 32]) {
|
||||
const challenge = crypto.randomBytes(length);
|
||||
|
||||
const response = RadiusAuthenticator.calculateChapResponse(chapId, password, challenge);
|
||||
|
||||
const chapPassword = Buffer.allocUnsafe(17);
|
||||
chapPassword.writeUInt8(chapId, 0);
|
||||
response.copy(chapPassword, 1);
|
||||
|
||||
const isValid = RadiusAuthenticator.verifyChapResponse(chapPassword, challenge, password);
|
||||
expect(isValid).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
190
test/server/test.packet.ts
Normal file
190
test/server/test.packet.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
RadiusPacket,
|
||||
ERadiusCode,
|
||||
ERadiusAttributeType,
|
||||
} from '../../ts_server/index.js';
|
||||
|
||||
tap.test('should encode and decode a basic Access-Request packet', async () => {
|
||||
const identifier = 42;
|
||||
const secret = 'testing123';
|
||||
const attributes = [
|
||||
{ type: ERadiusAttributeType.UserName, value: 'testuser' },
|
||||
{ type: ERadiusAttributeType.UserPassword, value: 'testpass' },
|
||||
{ type: ERadiusAttributeType.NasIpAddress, value: '192.168.1.1' },
|
||||
{ type: ERadiusAttributeType.NasPort, value: 1 },
|
||||
];
|
||||
|
||||
const packet = RadiusPacket.createAccessRequest(identifier, secret, attributes);
|
||||
|
||||
// Verify minimum packet size (20 bytes header)
|
||||
expect(packet.length).toBeGreaterThanOrEqual(RadiusPacket.MIN_PACKET_SIZE);
|
||||
|
||||
// Verify maximum packet size
|
||||
expect(packet.length).toBeLessThanOrEqual(RadiusPacket.MAX_PACKET_SIZE);
|
||||
|
||||
// Verify header
|
||||
expect(packet[0]).toEqual(ERadiusCode.AccessRequest);
|
||||
expect(packet[1]).toEqual(identifier);
|
||||
|
||||
// Decode the packet
|
||||
const decoded = RadiusPacket.decode(packet);
|
||||
expect(decoded.code).toEqual(ERadiusCode.AccessRequest);
|
||||
expect(decoded.identifier).toEqual(identifier);
|
||||
expect(decoded.authenticator.length).toEqual(16);
|
||||
expect(decoded.attributes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('should handle packet length validation', async () => {
|
||||
// Packet too short
|
||||
const shortPacket = Buffer.alloc(19);
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
RadiusPacket.decode(shortPacket);
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
expect(error).toBeDefined();
|
||||
expect(error!.message).toInclude('too short');
|
||||
});
|
||||
|
||||
tap.test('should handle invalid length in header', async () => {
|
||||
const packet = Buffer.alloc(20);
|
||||
packet[0] = ERadiusCode.AccessRequest;
|
||||
packet[1] = 1; // identifier
|
||||
packet.writeUInt16BE(10, 2); // length too small
|
||||
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
RadiusPacket.decode(packet);
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
|
||||
tap.test('should handle maximum packet size', async () => {
|
||||
const secret = 'testing123';
|
||||
const identifier = 1;
|
||||
|
||||
// Create a packet that would exceed max size
|
||||
const hugeAttributes: Array<{ type: number; value: Buffer }> = [];
|
||||
// Each attribute can be max 255 bytes. Create enough to exceed 4096
|
||||
for (let i = 0; i < 20; i++) {
|
||||
hugeAttributes.push({
|
||||
type: ERadiusAttributeType.ReplyMessage,
|
||||
value: Buffer.alloc(250, 65), // 250 bytes of 'A'
|
||||
});
|
||||
}
|
||||
|
||||
// This should throw because packet would be too large
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
// Manually build the packet to test size limit
|
||||
const rawAttrs = hugeAttributes.map((a) => ({
|
||||
type: a.type,
|
||||
value: a.value,
|
||||
}));
|
||||
|
||||
RadiusPacket.encode({
|
||||
code: ERadiusCode.AccessRequest,
|
||||
identifier,
|
||||
authenticator: Buffer.alloc(16),
|
||||
attributes: rawAttrs,
|
||||
});
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
expect(error).toBeDefined();
|
||||
expect(error!.message).toInclude('too large');
|
||||
});
|
||||
|
||||
tap.test('should parse and encode attributes correctly', async () => {
|
||||
const secret = 'testing123';
|
||||
const identifier = 1;
|
||||
|
||||
// Create packet with various attribute types
|
||||
const packet = RadiusPacket.createAccessRequest(identifier, secret, [
|
||||
{ type: 'User-Name', value: 'john.doe' }, // text
|
||||
{ type: 'NAS-IP-Address', value: '10.0.0.1' }, // address
|
||||
{ type: 'NAS-Port', value: 5060 }, // integer
|
||||
{ type: 'NAS-Identifier', value: 'nas01.example.com' }, // text
|
||||
]);
|
||||
|
||||
const decoded = RadiusPacket.decodeAndParse(packet);
|
||||
|
||||
// Find username
|
||||
const usernameAttr = decoded.parsedAttributes.find(
|
||||
(a) => a.type === ERadiusAttributeType.UserName
|
||||
);
|
||||
expect(usernameAttr).toBeDefined();
|
||||
expect(usernameAttr!.value).toEqual('john.doe');
|
||||
|
||||
// Find NAS-IP-Address
|
||||
const nasIpAttr = decoded.parsedAttributes.find(
|
||||
(a) => a.type === ERadiusAttributeType.NasIpAddress
|
||||
);
|
||||
expect(nasIpAttr).toBeDefined();
|
||||
expect(nasIpAttr!.value).toEqual('10.0.0.1');
|
||||
|
||||
// Find NAS-Port
|
||||
const nasPortAttr = decoded.parsedAttributes.find(
|
||||
(a) => a.type === ERadiusAttributeType.NasPort
|
||||
);
|
||||
expect(nasPortAttr).toBeDefined();
|
||||
expect(nasPortAttr!.value).toEqual(5060);
|
||||
});
|
||||
|
||||
tap.test('should create Access-Accept packet', async () => {
|
||||
const identifier = 1;
|
||||
const requestAuth = Buffer.alloc(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const packet = RadiusPacket.createAccessAccept(identifier, requestAuth, secret, [
|
||||
{ type: ERadiusAttributeType.ReplyMessage, value: 'Welcome!' },
|
||||
{ type: ERadiusAttributeType.SessionTimeout, value: 3600 },
|
||||
]);
|
||||
|
||||
const decoded = RadiusPacket.decode(packet);
|
||||
expect(decoded.code).toEqual(ERadiusCode.AccessAccept);
|
||||
expect(decoded.identifier).toEqual(identifier);
|
||||
});
|
||||
|
||||
tap.test('should create Access-Reject packet', async () => {
|
||||
const identifier = 1;
|
||||
const requestAuth = Buffer.alloc(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const packet = RadiusPacket.createAccessReject(identifier, requestAuth, secret, [
|
||||
{ type: ERadiusAttributeType.ReplyMessage, value: 'Invalid credentials' },
|
||||
]);
|
||||
|
||||
const decoded = RadiusPacket.decode(packet);
|
||||
expect(decoded.code).toEqual(ERadiusCode.AccessReject);
|
||||
});
|
||||
|
||||
tap.test('should create Access-Challenge packet', async () => {
|
||||
const identifier = 1;
|
||||
const requestAuth = Buffer.alloc(16);
|
||||
const secret = 'testing123';
|
||||
const state = Buffer.from('challenge-state-123');
|
||||
|
||||
const packet = RadiusPacket.createAccessChallenge(identifier, requestAuth, secret, [
|
||||
{ type: ERadiusAttributeType.ReplyMessage, value: 'Enter OTP' },
|
||||
{ type: ERadiusAttributeType.State, value: state },
|
||||
]);
|
||||
|
||||
const decoded = RadiusPacket.decode(packet);
|
||||
expect(decoded.code).toEqual(ERadiusCode.AccessChallenge);
|
||||
});
|
||||
|
||||
tap.test('should get code name', async () => {
|
||||
expect(RadiusPacket.getCodeName(ERadiusCode.AccessRequest)).toEqual('Access-Request');
|
||||
expect(RadiusPacket.getCodeName(ERadiusCode.AccessAccept)).toEqual('Access-Accept');
|
||||
expect(RadiusPacket.getCodeName(ERadiusCode.AccessReject)).toEqual('Access-Reject');
|
||||
expect(RadiusPacket.getCodeName(ERadiusCode.AccountingRequest)).toEqual('Accounting-Request');
|
||||
expect(RadiusPacket.getCodeName(ERadiusCode.AccountingResponse)).toEqual('Accounting-Response');
|
||||
expect(RadiusPacket.getCodeName(ERadiusCode.AccessChallenge)).toEqual('Access-Challenge');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
282
test/server/test.pap.ts
Normal file
282
test/server/test.pap.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as crypto from 'crypto';
|
||||
import { RadiusAuthenticator } from '../../ts_server/index.js';
|
||||
|
||||
tap.test('should encrypt and decrypt short password (< 16 chars)', async () => {
|
||||
const password = 'secret';
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(
|
||||
password,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
// Encrypted length should be multiple of 16
|
||||
expect(encrypted.length).toEqual(16);
|
||||
|
||||
const decrypted = RadiusAuthenticator.decryptPassword(
|
||||
encrypted,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(decrypted).toEqual(password);
|
||||
});
|
||||
|
||||
tap.test('should encrypt and decrypt exactly 16-char password', async () => {
|
||||
const password = '1234567890123456'; // Exactly 16 characters
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(
|
||||
password,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(encrypted.length).toEqual(16);
|
||||
|
||||
const decrypted = RadiusAuthenticator.decryptPassword(
|
||||
encrypted,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(decrypted).toEqual(password);
|
||||
});
|
||||
|
||||
tap.test('should encrypt and decrypt long password (> 16 chars)', async () => {
|
||||
const password = 'thisisaverylongpasswordthatexceeds16characters';
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(
|
||||
password,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
// Should be padded to next multiple of 16
|
||||
expect(encrypted.length % 16).toEqual(0);
|
||||
expect(encrypted.length).toBeGreaterThan(16);
|
||||
|
||||
const decrypted = RadiusAuthenticator.decryptPassword(
|
||||
encrypted,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(decrypted).toEqual(password);
|
||||
});
|
||||
|
||||
tap.test('should encrypt and decrypt password near max length (128 chars)', async () => {
|
||||
const password = 'a'.repeat(120); // Near max of 128
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(
|
||||
password,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
// Should be limited to 128 bytes
|
||||
expect(encrypted.length).toBeLessThanOrEqual(128);
|
||||
|
||||
const decrypted = RadiusAuthenticator.decryptPassword(
|
||||
encrypted,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(decrypted).toEqual(password);
|
||||
});
|
||||
|
||||
tap.test('should truncate password exceeding 128 chars', async () => {
|
||||
const password = 'a'.repeat(150); // Exceeds max
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(
|
||||
password,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
// Should be exactly 128 bytes (max)
|
||||
expect(encrypted.length).toEqual(128);
|
||||
|
||||
// Decrypted will be truncated to 128 chars
|
||||
const decrypted = RadiusAuthenticator.decryptPassword(
|
||||
encrypted,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(decrypted.length).toBeLessThanOrEqual(128);
|
||||
});
|
||||
|
||||
tap.test('should handle empty password', async () => {
|
||||
const password = '';
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(
|
||||
password,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
// Even empty password is padded to 16 bytes
|
||||
expect(encrypted.length).toEqual(16);
|
||||
|
||||
const decrypted = RadiusAuthenticator.decryptPassword(
|
||||
encrypted,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(decrypted).toEqual(password);
|
||||
});
|
||||
|
||||
tap.test('should fail to decrypt with wrong secret', async () => {
|
||||
const password = 'mypassword';
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
const wrongSecret = 'wrongsecret';
|
||||
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(
|
||||
password,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
const decrypted = RadiusAuthenticator.decryptPassword(
|
||||
encrypted,
|
||||
requestAuthenticator,
|
||||
wrongSecret
|
||||
);
|
||||
|
||||
// Should not match original password
|
||||
expect(decrypted).not.toEqual(password);
|
||||
});
|
||||
|
||||
tap.test('should fail to decrypt with wrong authenticator', async () => {
|
||||
const password = 'mypassword';
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const wrongAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(
|
||||
password,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
const decrypted = RadiusAuthenticator.decryptPassword(
|
||||
encrypted,
|
||||
wrongAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
// Should not match original password
|
||||
expect(decrypted).not.toEqual(password);
|
||||
});
|
||||
|
||||
tap.test('should handle special characters in password', async () => {
|
||||
const password = '!@#$%^&*()_+-=[]{}|;:,.<>?';
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(
|
||||
password,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
const decrypted = RadiusAuthenticator.decryptPassword(
|
||||
encrypted,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(decrypted).toEqual(password);
|
||||
});
|
||||
|
||||
tap.test('should handle unicode characters in password', async () => {
|
||||
const password = 'パスワード密码'; // Japanese + Chinese
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(
|
||||
password,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
const decrypted = RadiusAuthenticator.decryptPassword(
|
||||
encrypted,
|
||||
requestAuthenticator,
|
||||
secret
|
||||
);
|
||||
|
||||
expect(decrypted).toEqual(password);
|
||||
});
|
||||
|
||||
tap.test('should reject invalid encrypted password length', async () => {
|
||||
const requestAuthenticator = crypto.randomBytes(16);
|
||||
const secret = 'testing123';
|
||||
|
||||
// Not a multiple of 16
|
||||
const invalidEncrypted = Buffer.alloc(15);
|
||||
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
RadiusAuthenticator.decryptPassword(invalidEncrypted, requestAuthenticator, secret);
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
|
||||
expect(error).toBeDefined();
|
||||
expect(error!.message).toInclude('Invalid');
|
||||
});
|
||||
|
||||
tap.test('PAP encryption matches RFC 2865 algorithm', async () => {
|
||||
// Test that our implementation matches the RFC 2865 algorithm:
|
||||
// b1 = MD5(S + RA) c(1) = p1 xor b1
|
||||
// b2 = MD5(S + c(1)) c(2) = p2 xor b2
|
||||
|
||||
const password = 'testpassword12345678'; // Longer than 16 to test chaining
|
||||
const requestAuth = Buffer.alloc(16, 0x42); // Fixed for deterministic test
|
||||
const secret = 'sharedsecret';
|
||||
|
||||
// Our implementation
|
||||
const encrypted = RadiusAuthenticator.encryptPassword(password, requestAuth, secret);
|
||||
|
||||
// Manual calculation per RFC
|
||||
const paddedLength = Math.ceil(password.length / 16) * 16;
|
||||
const padded = Buffer.alloc(paddedLength, 0);
|
||||
Buffer.from(password, 'utf8').copy(padded);
|
||||
|
||||
const expected = Buffer.alloc(paddedLength);
|
||||
let previousCipher = requestAuth;
|
||||
|
||||
for (let i = 0; i < paddedLength; i += 16) {
|
||||
const md5 = crypto.createHash('md5');
|
||||
md5.update(Buffer.from(secret, 'utf8'));
|
||||
md5.update(previousCipher);
|
||||
const b = md5.digest();
|
||||
|
||||
for (let j = 0; j < 16; j++) {
|
||||
expected[i + j] = padded[i + j] ^ b[j];
|
||||
}
|
||||
|
||||
previousCipher = expected.subarray(i, i + 16);
|
||||
}
|
||||
|
||||
expect(encrypted.equals(expected)).toBeTruthy();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,8 +0,0 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartradius from '../ts/index.js'
|
||||
|
||||
tap.test('first test', async () => {
|
||||
console.log(smartradius)
|
||||
})
|
||||
|
||||
export default tap.start()
|
||||
8
ts/00_commitinfo_data.ts
Normal file
8
ts/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartradius',
|
||||
version: '1.1.0',
|
||||
description: 'A RADIUS server and client implementation for Node.js with full RFC 2865/2866 compliance'
|
||||
}
|
||||
20
ts/index.ts
20
ts/index.ts
@@ -1,3 +1,19 @@
|
||||
import * as plugins from './plugins.js';
|
||||
// @push.rocks/smartradius
|
||||
// RADIUS Server and Client Library
|
||||
// Implements RFC 2865 (Authentication) and RFC 2866 (Accounting)
|
||||
|
||||
export let demoExport = 'Hi there! :) This is an exported string';
|
||||
// Re-export shared protocol definitions
|
||||
export * from '../ts_shared/index.js';
|
||||
|
||||
// Re-export server module
|
||||
export * from '../ts_server/index.js';
|
||||
|
||||
// Re-export client module
|
||||
export { RadiusClient } from '../ts_client/index.js';
|
||||
export type {
|
||||
IRadiusClientOptions,
|
||||
IClientAuthRequest,
|
||||
IClientAuthResponse,
|
||||
IClientAccountingRequest,
|
||||
IClientAccountingResponse,
|
||||
} from '../ts_client/index.js';
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
export const packageDir = plugins.path.join(
|
||||
plugins.smartpath.get.dirnameFromImportMetaUrl(import.meta.url),
|
||||
'../'
|
||||
);
|
||||
@@ -1,9 +0,0 @@
|
||||
// native scope
|
||||
import * as path from 'path';
|
||||
|
||||
export { path };
|
||||
|
||||
// @push.rocks scope
|
||||
import * as smartpath from '@push.rocks/smartpath';
|
||||
|
||||
export { smartpath };
|
||||
93
ts/readme.md
Normal file
93
ts/readme.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# @push.rocks/smartradius
|
||||
|
||||
> 🔐 Complete RADIUS Server and Client Library for Node.js
|
||||
|
||||
## Overview
|
||||
|
||||
This is the main entry point for the smartradius library. It re-exports all functionality from the server, client, and shared modules, providing a unified API for RADIUS operations.
|
||||
|
||||
## What Gets Exported
|
||||
|
||||
This module combines exports from:
|
||||
|
||||
- **ts_shared** - Protocol definitions, enums, and core interfaces
|
||||
- **ts_server** - Server implementation and utilities
|
||||
- **ts_client** - Client implementation
|
||||
|
||||
## Quick Import
|
||||
|
||||
```typescript
|
||||
// Everything from one import
|
||||
import {
|
||||
// Server
|
||||
RadiusServer,
|
||||
RadiusPacket,
|
||||
RadiusAttributes,
|
||||
RadiusAuthenticator,
|
||||
|
||||
// Client
|
||||
RadiusClient,
|
||||
|
||||
// Shared Enums
|
||||
ERadiusCode,
|
||||
ERadiusAttributeType,
|
||||
EAcctStatusType,
|
||||
EAcctTerminateCause,
|
||||
|
||||
// Shared Interfaces
|
||||
IRadiusPacket,
|
||||
IParsedAttribute,
|
||||
|
||||
// Server Interfaces
|
||||
IRadiusServerOptions,
|
||||
IAuthenticationRequest,
|
||||
IAuthenticationResponse,
|
||||
|
||||
// Client Interfaces
|
||||
IRadiusClientOptions,
|
||||
IClientAuthRequest,
|
||||
IClientAuthResponse,
|
||||
} from '@push.rocks/smartradius';
|
||||
```
|
||||
|
||||
## Sub-modules
|
||||
|
||||
For more targeted imports, see the individual module documentation:
|
||||
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| [ts_shared](../ts_shared/readme.md) | Protocol definitions and enums |
|
||||
| [ts_server](../ts_server/readme.md) | Server implementation |
|
||||
| [ts_client](../ts_client/readme.md) | Client implementation |
|
||||
|
||||
## Usage Example
|
||||
|
||||
```typescript
|
||||
import { RadiusServer, RadiusClient, ERadiusCode } from '@push.rocks/smartradius';
|
||||
|
||||
// Create server
|
||||
const server = new RadiusServer({
|
||||
defaultSecret: 'testing123',
|
||||
authenticationHandler: async (req) => ({
|
||||
code: req.password === 'secret'
|
||||
? ERadiusCode.AccessAccept
|
||||
: ERadiusCode.AccessReject
|
||||
}),
|
||||
});
|
||||
await server.start();
|
||||
|
||||
// Create client
|
||||
const client = new RadiusClient({
|
||||
host: '127.0.0.1',
|
||||
secret: 'testing123',
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
// Authenticate
|
||||
const result = await client.authenticatePap('user', 'secret');
|
||||
console.log('Accepted:', result.accepted);
|
||||
|
||||
// Cleanup
|
||||
await client.disconnect();
|
||||
await server.stop();
|
||||
```
|
||||
1
ts/tspublish.json
Normal file
1
ts/tspublish.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "order": 4 }
|
||||
531
ts_client/classes.radiusclient.ts
Normal file
531
ts_client/classes.radiusclient.ts
Normal 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
4
ts_client/index.ts
Normal 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
96
ts_client/interfaces.ts
Normal 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
13
ts_client/plugins.ts
Normal 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
151
ts_client/readme.md
Normal 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
1
ts_client/tspublish.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "order": 3 }
|
||||
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 }
|
||||
222
ts_shared/enums.ts
Normal file
222
ts_shared/enums.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* RADIUS Protocol Enums
|
||||
* Based on RFC 2865 (Authentication) and RFC 2866 (Accounting)
|
||||
*/
|
||||
|
||||
/**
|
||||
* RADIUS Packet Codes (RFC 2865 Section 3)
|
||||
*/
|
||||
export enum ERadiusCode {
|
||||
AccessRequest = 1,
|
||||
AccessAccept = 2,
|
||||
AccessReject = 3,
|
||||
AccountingRequest = 4,
|
||||
AccountingResponse = 5,
|
||||
AccessChallenge = 11,
|
||||
StatusServer = 12, // Experimental
|
||||
StatusClient = 13, // Experimental
|
||||
}
|
||||
|
||||
/**
|
||||
* RADIUS Attribute Types (RFC 2865 Section 5)
|
||||
*/
|
||||
export enum ERadiusAttributeType {
|
||||
UserName = 1,
|
||||
UserPassword = 2,
|
||||
ChapPassword = 3,
|
||||
NasIpAddress = 4,
|
||||
NasPort = 5,
|
||||
ServiceType = 6,
|
||||
FramedProtocol = 7,
|
||||
FramedIpAddress = 8,
|
||||
FramedIpNetmask = 9,
|
||||
FramedRouting = 10,
|
||||
FilterId = 11,
|
||||
FramedMtu = 12,
|
||||
FramedCompression = 13,
|
||||
LoginIpHost = 14,
|
||||
LoginService = 15,
|
||||
LoginTcpPort = 16,
|
||||
ReplyMessage = 18,
|
||||
CallbackNumber = 19,
|
||||
CallbackId = 20,
|
||||
FramedRoute = 22,
|
||||
FramedIpxNetwork = 23,
|
||||
State = 24,
|
||||
Class = 25,
|
||||
VendorSpecific = 26,
|
||||
SessionTimeout = 27,
|
||||
IdleTimeout = 28,
|
||||
TerminationAction = 29,
|
||||
CalledStationId = 30,
|
||||
CallingStationId = 31,
|
||||
NasIdentifier = 32,
|
||||
ProxyState = 33,
|
||||
LoginLatService = 34,
|
||||
LoginLatNode = 35,
|
||||
LoginLatGroup = 36,
|
||||
FramedAppleTalkLink = 37,
|
||||
FramedAppleTalkNetwork = 38,
|
||||
FramedAppleTalkZone = 39,
|
||||
ChapChallenge = 60,
|
||||
NasPortType = 61,
|
||||
PortLimit = 62,
|
||||
LoginLatPort = 63,
|
||||
// Accounting attributes (RFC 2866)
|
||||
AcctStatusType = 40,
|
||||
AcctDelayTime = 41,
|
||||
AcctInputOctets = 42,
|
||||
AcctOutputOctets = 43,
|
||||
AcctSessionId = 44,
|
||||
AcctAuthentic = 45,
|
||||
AcctSessionTime = 46,
|
||||
AcctInputPackets = 47,
|
||||
AcctOutputPackets = 48,
|
||||
AcctTerminateCause = 49,
|
||||
AcctMultiSessionId = 50,
|
||||
AcctLinkCount = 51,
|
||||
// EAP support
|
||||
EapMessage = 79,
|
||||
MessageAuthenticator = 80,
|
||||
}
|
||||
|
||||
/**
|
||||
* Service-Type values (RFC 2865 Section 5.6)
|
||||
*/
|
||||
export enum EServiceType {
|
||||
Login = 1,
|
||||
Framed = 2,
|
||||
CallbackLogin = 3,
|
||||
CallbackFramed = 4,
|
||||
Outbound = 5,
|
||||
Administrative = 6,
|
||||
NasPrompt = 7,
|
||||
AuthenticateOnly = 8,
|
||||
CallbackNasPrompt = 9,
|
||||
CallCheck = 10,
|
||||
CallbackAdministrative = 11,
|
||||
}
|
||||
|
||||
/**
|
||||
* Framed-Protocol values (RFC 2865 Section 5.7)
|
||||
*/
|
||||
export enum EFramedProtocol {
|
||||
Ppp = 1,
|
||||
Slip = 2,
|
||||
Arap = 3,
|
||||
Gandalf = 4,
|
||||
Xylogics = 5,
|
||||
X75 = 6,
|
||||
}
|
||||
|
||||
/**
|
||||
* Framed-Routing values (RFC 2865 Section 5.10)
|
||||
*/
|
||||
export enum EFramedRouting {
|
||||
None = 0,
|
||||
Send = 1,
|
||||
Listen = 2,
|
||||
SendAndListen = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* Framed-Compression values (RFC 2865 Section 5.13)
|
||||
*/
|
||||
export enum EFramedCompression {
|
||||
None = 0,
|
||||
VjTcpIp = 1,
|
||||
IpxHeaderCompression = 2,
|
||||
StacLzs = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* Login-Service values (RFC 2865 Section 5.15)
|
||||
*/
|
||||
export enum ELoginService {
|
||||
Telnet = 0,
|
||||
Rlogin = 1,
|
||||
TcpClear = 2,
|
||||
PortMaster = 3,
|
||||
Lat = 4,
|
||||
X25Pad = 5,
|
||||
X25T3Pos = 6,
|
||||
TcpClearQuiet = 8,
|
||||
}
|
||||
|
||||
/**
|
||||
* Termination-Action values (RFC 2865 Section 5.29)
|
||||
*/
|
||||
export enum ETerminationAction {
|
||||
Default = 0,
|
||||
RadiusRequest = 1,
|
||||
}
|
||||
|
||||
/**
|
||||
* NAS-Port-Type values (RFC 2865 Section 5.41)
|
||||
*/
|
||||
export enum ENasPortType {
|
||||
Async = 0,
|
||||
Sync = 1,
|
||||
IsdnSync = 2,
|
||||
IsdnAsyncV120 = 3,
|
||||
IsdnAsyncV110 = 4,
|
||||
Virtual = 5,
|
||||
Piafs = 6,
|
||||
HdlcClearChannel = 7,
|
||||
X25 = 8,
|
||||
X75 = 9,
|
||||
G3Fax = 10,
|
||||
Sdsl = 11,
|
||||
AdslCap = 12,
|
||||
AdslDmt = 13,
|
||||
Idsl = 14,
|
||||
Ethernet = 15,
|
||||
Xdsl = 16,
|
||||
Cable = 17,
|
||||
WirelessOther = 18,
|
||||
WirelessIeee80211 = 19,
|
||||
}
|
||||
|
||||
/**
|
||||
* Acct-Status-Type values (RFC 2866 Section 5.1)
|
||||
*/
|
||||
export enum EAcctStatusType {
|
||||
Start = 1,
|
||||
Stop = 2,
|
||||
InterimUpdate = 3,
|
||||
AccountingOn = 7,
|
||||
AccountingOff = 8,
|
||||
}
|
||||
|
||||
/**
|
||||
* Acct-Authentic values (RFC 2866 Section 5.6)
|
||||
*/
|
||||
export enum EAcctAuthentic {
|
||||
Radius = 1,
|
||||
Local = 2,
|
||||
Remote = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* Acct-Terminate-Cause values (RFC 2866 Section 5.10)
|
||||
*/
|
||||
export enum EAcctTerminateCause {
|
||||
UserRequest = 1,
|
||||
LostCarrier = 2,
|
||||
LostService = 3,
|
||||
IdleTimeout = 4,
|
||||
SessionTimeout = 5,
|
||||
AdminReset = 6,
|
||||
AdminReboot = 7,
|
||||
PortError = 8,
|
||||
NasError = 9,
|
||||
NasRequest = 10,
|
||||
NasReboot = 11,
|
||||
PortUnneeded = 12,
|
||||
PortPreempted = 13,
|
||||
PortSuspended = 14,
|
||||
ServiceUnavailable = 15,
|
||||
Callback = 16,
|
||||
UserError = 17,
|
||||
HostRequest = 18,
|
||||
}
|
||||
5
ts_shared/index.ts
Normal file
5
ts_shared/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// RADIUS Protocol Shared Module
|
||||
// Contains RFC 2865/2866 protocol definitions used by both server and client
|
||||
|
||||
export * from './enums.js';
|
||||
export * from './interfaces.js';
|
||||
65
ts_shared/interfaces.ts
Normal file
65
ts_shared/interfaces.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* RADIUS Protocol Core Interfaces
|
||||
* Based on RFC 2865 (Authentication) and RFC 2866 (Accounting)
|
||||
*/
|
||||
|
||||
import { ERadiusCode } from './enums.js';
|
||||
|
||||
/**
|
||||
* Attribute value type
|
||||
*/
|
||||
export type TAttributeValueType = 'text' | 'string' | 'address' | 'integer' | 'time' | 'vsa';
|
||||
|
||||
/**
|
||||
* Attribute definition
|
||||
*/
|
||||
export interface IAttributeDefinition {
|
||||
type: number;
|
||||
name: string;
|
||||
valueType: TAttributeValueType;
|
||||
encrypted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw attribute (type-length-value)
|
||||
*/
|
||||
export interface IRadiusAttribute {
|
||||
type: number;
|
||||
value: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed attribute with named value
|
||||
*/
|
||||
export interface IParsedAttribute {
|
||||
type: number;
|
||||
name: string;
|
||||
value: string | number | Buffer;
|
||||
rawValue: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vendor-Specific Attribute
|
||||
*/
|
||||
export interface IVendorSpecificAttribute {
|
||||
vendorId: number;
|
||||
vendorType: number;
|
||||
vendorValue: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* RADIUS Packet structure
|
||||
*/
|
||||
export interface IRadiusPacket {
|
||||
code: ERadiusCode;
|
||||
identifier: number;
|
||||
authenticator: Buffer;
|
||||
attributes: IRadiusAttribute[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed RADIUS packet with named attributes
|
||||
*/
|
||||
export interface IParsedRadiusPacket extends IRadiusPacket {
|
||||
parsedAttributes: IParsedAttribute[];
|
||||
}
|
||||
81
ts_shared/readme.md
Normal file
81
ts_shared/readme.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# @push.rocks/smartradius/shared
|
||||
|
||||
> 📡 RADIUS Protocol Definitions - Shared types and enums for RFC 2865/2866 compliance
|
||||
|
||||
## Overview
|
||||
|
||||
This module contains the core RADIUS protocol definitions shared between the server and client implementations. It provides all the RFC 2865 (Authentication) and RFC 2866 (Accounting) enums and interfaces that represent the RADIUS protocol.
|
||||
|
||||
## Contents
|
||||
|
||||
### Enums (`enums.ts`)
|
||||
|
||||
All RFC-compliant protocol constants:
|
||||
|
||||
| Enum | Description | RFC Reference |
|
||||
|------|-------------|---------------|
|
||||
| `ERadiusCode` | Packet types (Access-Request, Access-Accept, etc.) | RFC 2865 §3 |
|
||||
| `ERadiusAttributeType` | Standard attribute types (1-63, 79-80) | RFC 2865 §5 |
|
||||
| `EServiceType` | Service-Type attribute values | RFC 2865 §5.6 |
|
||||
| `EFramedProtocol` | Framed-Protocol values | RFC 2865 §5.7 |
|
||||
| `EFramedRouting` | Framed-Routing values | RFC 2865 §5.10 |
|
||||
| `EFramedCompression` | Framed-Compression values | RFC 2865 §5.13 |
|
||||
| `ELoginService` | Login-Service values | RFC 2865 §5.15 |
|
||||
| `ETerminationAction` | Termination-Action values | RFC 2865 §5.29 |
|
||||
| `ENasPortType` | NAS-Port-Type values | RFC 2865 §5.41 |
|
||||
| `EAcctStatusType` | Acct-Status-Type values | RFC 2866 §5.1 |
|
||||
| `EAcctAuthentic` | Acct-Authentic values | RFC 2866 §5.6 |
|
||||
| `EAcctTerminateCause` | Acct-Terminate-Cause values | RFC 2866 §5.10 |
|
||||
|
||||
### Interfaces (`interfaces.ts`)
|
||||
|
||||
Core data structures representing RADIUS protocol elements:
|
||||
|
||||
| Interface | Description |
|
||||
|-----------|-------------|
|
||||
| `TAttributeValueType` | Value type union ('text', 'string', 'address', 'integer', 'time', 'vsa') |
|
||||
| `IAttributeDefinition` | Attribute metadata (type, name, valueType, encrypted) |
|
||||
| `IRadiusAttribute` | Raw TLV (type-length-value) format |
|
||||
| `IParsedAttribute` | Parsed attribute with name and decoded value |
|
||||
| `IVendorSpecificAttribute` | VSA format (vendorId, vendorType, vendorValue) |
|
||||
| `IRadiusPacket` | Packet structure (code, identifier, authenticator, attributes) |
|
||||
| `IParsedRadiusPacket` | Packet with parsed attributes |
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ERadiusCode,
|
||||
ERadiusAttributeType,
|
||||
EAcctStatusType,
|
||||
IRadiusPacket,
|
||||
IParsedAttribute,
|
||||
} from '@push.rocks/smartradius';
|
||||
|
||||
// Check packet type
|
||||
if (packet.code === ERadiusCode.AccessRequest) {
|
||||
// Handle authentication request
|
||||
}
|
||||
|
||||
// Check accounting status
|
||||
if (request.statusType === EAcctStatusType.Start) {
|
||||
// Session started
|
||||
}
|
||||
|
||||
// Access attribute by type
|
||||
const username = packet.attributes.find(
|
||||
a => a.type === ERadiusAttributeType.UserName
|
||||
);
|
||||
```
|
||||
|
||||
## Why a Shared Module?
|
||||
|
||||
This module provides clear separation between:
|
||||
- **Protocol definitions** (what RADIUS is) - lives here
|
||||
- **Server implementation** (how to serve RADIUS) - lives in ts_server
|
||||
- **Client implementation** (how to consume RADIUS) - lives in ts_client
|
||||
|
||||
This separation provides:
|
||||
- 🎯 Better semantic clarity for API consumers
|
||||
- 📦 Smaller imports when only types are needed
|
||||
- 🔄 Clean dependency graph (shared → server/client → main)
|
||||
1
ts_shared/tspublish.json
Normal file
1
ts_shared/tspublish.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "order": 1 }
|
||||
Reference in New Issue
Block a user