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:
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();
|
||||
Reference in New Issue
Block a user