Skip to content

kowalski21/tsrad

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

tsrad

A TypeScript RADIUS client/server library. Complete port of pyrad to idiomatic TypeScript.

Implements RFC 2865 (Authentication), RFC 2866 (Accounting), RFC 2868 (Tunnel Attributes), and RFC 3576 (Dynamic Authorization / CoA).


Philosophy

Why tsrad exists

RADIUS is the backbone of AAA (Authentication, Authorization, Accounting) in network infrastructure. Every ISP, enterprise Wi-Fi deployment, and VPN concentrator speaks RADIUS. Yet the Node.js ecosystem has had no serious, complete RADIUS library — just thin wrappers that handle basic auth and nothing else.

tsrad is a faithful port of pyrad, the most battle-tested Python RADIUS library, brought to TypeScript with full type safety. The goal is a library that ISP engineers, network automation developers, and infrastructure teams can use to build real production RADIUS systems in Node.js — not toy examples, but actual NAS integration, subscriber management, and dynamic authorization.

Design decisions

Port, don't reinvent. pyrad has been used in production for over a decade. Its architecture is proven. Rather than redesigning from scratch and introducing subtle protocol bugs, tsrad preserves pyrad's structure: the same class hierarchy (Host -> Client/Server), the same packet model, the same dictionary parser. If you know pyrad, you know tsrad.

FreeRADIUS dictionary compatibility. The RADIUS protocol defines hundreds of attributes across dozens of RFCs and vendor extensions. Rather than hardcoding these, tsrad uses the same dictionary file format as FreeRADIUS — the de facto standard RADIUS server. You can point tsrad at your existing FreeRADIUS dictionary files and everything works. This also means you get vendor-specific attributes (Mikrotik, Cisco, Juniper, etc.) for free.

Buffers, not strings, for secrets. Shared secrets are binary data. tsrad enforces Buffer for all secrets to prevent encoding bugs that cause authentication failures. This is a deliberate friction — Buffer.from('secret') is slightly more verbose than a bare string, but it eliminates an entire class of interoperability bugs.

Zero runtime dependencies. tsrad uses only Node.js built-in modules (node:dgram, node:crypto, node:fs, node:path, node:events). No npm dependencies means no supply chain risk, no version conflicts, no transitive vulnerabilities. The only dev dependencies are TypeScript and @types/node.

Subclass, don't configure. The server uses a handler pattern: you subclass Server and override handleAuthPacket(), handleAcctPacket(), etc. This is more explicit than callback registration and gives you full control over the request lifecycle. Each handler receives the parsed packet with source info attached — you decode attributes, make your authorization decision, build a reply, and send it back.

Protocol correctness over convenience. tsrad implements the full authenticator verification chain, Message-Authenticator (HMAC-MD5), salt encryption for tunnel attributes, CHAP verification, and proper retry semantics with Acct-Delay-Time increment. These aren't optional — they're what makes a RADIUS implementation actually work with real NAS equipment.

Architecture

                +-----------+
                |   Host    |  Base class: ports, dictionary, packet factories
                +-----+-----+
                      |
            +---------+---------+
            |                   |
      +-----+-----+      +-----+-----+
      |   Client   |      |   Server   |
      +-----+-----+      +-----+-----+
            |                   |
     UDP send/recv        UDP listeners
     retry + timeout      handler dispatch

The packet hierarchy is flat:

Packet (base)
  |- AuthPacket   (Access-Request, code 1)
  |- AcctPacket   (Accounting-Request, code 4)
  |- CoAPacket    (CoA-Request/Disconnect-Request, code 43/40)

All packets share the same attribute storage, encoding, and decoding logic. The subclasses differ only in how they compute the authenticator (random for auth requests, MD5-based for acct/CoA) and what default reply codes they use.


Development

Prerequisites

  • Node.js >= 18 (uses node:test built-in test runner)
  • TypeScript >= 5.7

Setup

cd tsrad
npm install

Build

# One-shot compile
npx tsc

# Watch mode — recompiles on file changes
npm run dev

TypeScript source lives in src/, compiled JavaScript goes to dist/. The tsconfig targets ES2022 with Node16 module resolution and strict mode enabled. Output is CommonJS.

Run tests

# Build first, then test
npx tsc && npm test

Tests use Node.js built-in test runner (node:test + node:assert/strict). There are 303 tests across 14 test files covering every module:

Test file Tests Coverage
bidict.test.ts 8 Forward/backward access, deletion, Buffer keys
tools.test.ts 32 All data type encode/decode, dispatch, errors
dictionary.test.ts 19 Parsing, vendors, TLV, hex/octal codes, errors
packet.test.ts 62 Construction, attributes, encode/decode, auth, vendor, TLV, CHAP, Message-Authenticator, salt encryption
host.test.ts 7 Construction, packet factories
client.test.ts 8 Construction, packet creation, timeout with real UDP
server.test.ts 12 Construction, auth/acct round-trip integration, error handling
db.test.ts 25 Schema, operators, queries, PAP/CHAP auth, acct, groups, DatabaseServer integration

The integration tests in server.test.ts spin up a real UDP server and client on localhost, so they test the full encode-send-receive-decode-reply cycle.

Project structure

tsrad/
  src/
    index.ts            Barrel exports — the public API surface
    bidict.ts           Bidirectional map (Buffer-safe key comparison)
    dictfile.ts         Dictionary file reader with $INCLUDE support
    dictionary.ts       FreeRADIUS dictionary parser
    tools.ts            Attribute type encoding/decoding (RFC 2865 types)
    packet.ts           Packet classes, authenticator, encryption
    host.ts             Base class for Client and Server
    client.ts           RADIUS client with retry/timeout
    server.ts           RADIUS server with handler dispatch
    db.ts               Database integration (knex, rlm_sql compatible)
    *.test.ts           Tests (co-located with source)
  tests/
    data/
      simple            Minimal dictionary for basic tests
      full              Dictionary with vendors, values, TLV, encryption
      chap              CHAP-specific attributes
      realistic         RFC 2865/2866 dictionary (~60 attributes)
      db                Standard RADIUS attributes for database tests
  docs/                 API reference and guides
  dist/                 Compiled output (gitignored)
  package.json
  tsconfig.json

Development workflow

  1. Edit .ts files in src/
  2. Run npx tsc to compile (or npm run dev for watch mode)
  3. Run npm test to verify
  4. Tests run against compiled JS in dist/, so always compile before testing

Writing tests

Tests live next to the source they test. Use Node.js built-in test runner:

import { describe, it, beforeEach } from 'node:test';
import * as assert from 'node:assert/strict';

describe('MyFeature', () => {
  it('does the thing', () => {
    assert.equal(1 + 1, 2);
  });
});

Test dictionary files go in tests/data/. Use the FreeRADIUS dictionary format — see existing files for examples.

For tests that need file paths, use __dirname (not import.meta.dirname — the output is CJS):

import * as path from 'node:path';
const dataDir = path.resolve(__dirname, '..', 'tests', 'data');
const dict = new Dictionary(path.join(dataDir, 'realistic'));

Usage

Loading a dictionary

Every RADIUS interaction starts with a dictionary. The dictionary defines what attributes exist, their numeric codes, data types, and any named values.

import { Dictionary } from 'tsrad';

// Load a single file
const dict = new Dictionary('/usr/share/freeradius/dictionary');

// Load multiple files
const dict = new Dictionary(
  '/usr/share/freeradius/dictionary.rfc2865',
  '/usr/share/freeradius/dictionary.rfc2866',
  '/usr/share/freeradius/dictionary.rfc3576',
  '/usr/share/freeradius/dictionary.mikrotik',
);

// Load additional files after construction
dict.readDictionary('/path/to/dictionary.custom');

// Empty dictionary (for low-level use with numeric attribute codes)
const empty = new Dictionary();

Dictionary files use the FreeRADIUS format. Here's a minimal one:

# my-dictionary
ATTRIBUTE  User-Name         1   string
ATTRIBUTE  User-Password     2   string    encrypt=1
ATTRIBUTE  NAS-IP-Address    4   ipaddr
ATTRIBUTE  NAS-Port          5   integer
ATTRIBUTE  Service-Type      6   integer
ATTRIBUTE  Framed-IP-Address 8   ipaddr
ATTRIBUTE  Acct-Status-Type  40  integer
ATTRIBUTE  Acct-Session-Id   44  string

VALUE  Service-Type     Login-User   1
VALUE  Service-Type     Framed-User  2
VALUE  Acct-Status-Type Start        1
VALUE  Acct-Status-Type Stop         2
VALUE  Acct-Status-Type Interim-Update 3

With vendor-specific attributes:

VENDOR  Mikrotik  14988

BEGIN-VENDOR Mikrotik
ATTRIBUTE  Mikrotik-Recv-Limit    1  integer
ATTRIBUTE  Mikrotik-Xmit-Limit    2  integer
ATTRIBUTE  Mikrotik-Rate-Limit    8  string
ATTRIBUTE  Mikrotik-Realm        11  string
ATTRIBUTE  Mikrotik-Wireless-PSK 17  string
END-VENDOR Mikrotik

Inspecting the dictionary

const dict = new Dictionary('/path/to/dictionary');

// Check if an attribute is defined
dict.has('User-Name');                  // true

// Get the full attribute definition
const attr = dict.get('User-Name')!;
attr.name;        // 'User-Name'
attr.code;        // 1
attr.type;        // 'string'
attr.vendor;      // '' (empty for standard attributes)
attr.encrypt;     // 0 (no encryption)

// Vendor attribute
const vattr = dict.get('Mikrotik-Rate-Limit')!;
vattr.vendor;     // 'Mikrotik'
vattr.code;       // 8

// Look up vendor ID
dict.vendors.getForward('Mikrotik');    // 14988
dict.vendors.getBackward(14988);        // 'Mikrotik'

// Look up attribute index key
dict.attrindex.getForward('User-Name');           // 1
dict.attrindex.getForward('Mikrotik-Rate-Limit'); // [14988, 8]

PAP authentication (client)

The most common RADIUS operation: send an Access-Request with username and encrypted password, get back Access-Accept or Access-Reject.

import {
  Client, Dictionary,
  AccessAccept, AccessReject, AccessChallenge,
} from 'tsrad';

const dict = new Dictionary('/usr/share/freeradius/dictionary');
const client = new Client({
  server: '192.168.1.1',
  secret: Buffer.from('testing123'),
  dict,
  timeout: 5,    // seconds per attempt
  retries: 3,    // retry count
});

try {
  // Build the request
  const req = client.createAuthPacket();
  req.addAttribute('User-Name', 'alice@example.com');
  req.set('User-Password', [req.pwCrypt('s3cret!')]);
  req.addAttribute('NAS-IP-Address', '10.0.0.1');
  req.addAttribute('NAS-Port', 0);
  req.addAttribute('Service-Type', 'Framed-User');

  // Send and wait for reply
  const reply = await client.sendPacket(req);

  switch (reply.code) {
    case AccessAccept:
      console.log('Authenticated!');
      if (reply.has('Framed-IP-Address')) {
        console.log('Assigned IP:', reply.getAttribute('Framed-IP-Address')[0]);
      }
      if (reply.has('Session-Timeout')) {
        console.log('Session timeout:', reply.getAttribute('Session-Timeout')[0], 'seconds');
      }
      break;

    case AccessReject:
      console.log('Rejected.');
      if (reply.has('Reply-Message')) {
        console.log('Reason:', reply.getAttribute('Reply-Message')[0]);
      }
      break;

    case AccessChallenge:
      console.log('Challenge received — MFA or EAP continuation needed');
      break;
  }
} finally {
  client.close();
}

CHAP authentication (client)

CHAP never sends the password in cleartext — not even encrypted. Instead, the client sends a hash of (CHAP-ID + password + challenge). The server must know the plaintext password to verify the hash.

import * as crypto from 'node:crypto';
import { Client, Dictionary, AccessAccept } from 'tsrad';

const dict = new Dictionary('/usr/share/freeradius/dictionary');
const client = new Client({
  server: '192.168.1.1',
  secret: Buffer.from('testing123'),
  dict,
});

const req = client.createAuthPacket();
req.addAttribute('User-Name', 'alice@example.com');

// Generate CHAP credentials
const chapId = Buffer.from([crypto.randomInt(0, 256)]);
const challenge = crypto.randomBytes(16);
const chapHash = crypto.createHash('md5')
  .update(chapId)
  .update(Buffer.from('s3cret!'))
  .update(challenge)
  .digest();

// CHAP-Password = 1 byte ID + 16 byte hash
req.set(3, [Buffer.concat([chapId, chapHash])]);    // code 3 = CHAP-Password
req.set(60, [challenge]);                             // code 60 = CHAP-Challenge

const reply = await client.sendPacket(req);
console.log(reply.code === AccessAccept ? 'OK' : 'FAIL');
client.close();

Message-Authenticator

RFC 3579 defines Message-Authenticator — an HMAC-MD5 signature over the entire packet. Required for EAP, recommended for all Access-Request packets to prevent spoofing.

// Option 1: enforce globally — every auth packet gets Message-Authenticator
const client = new Client({
  server: '192.168.1.1',
  secret: Buffer.from('testing123'),
  dict,
  enforceMA: true,
});

const req = client.createAuthPacket();
// Message-Authenticator is automatically added
req.addAttribute('User-Name', 'alice');
req.set('User-Password', [req.pwCrypt('password')]);
const reply = await client.sendPacket(req);

// Option 2: add per-packet
const req2 = client.createAuthPacket();
req2.addAttribute('User-Name', 'bob');
req2.set('User-Password', [req2.pwCrypt('password')]);
req2.addMessageAuthenticator();  // explicit
const reply2 = await client.sendPacket(req2);

client.close();

On the server side, verify it:

handleAuthPacket(pkt: RadiusPacket) {
  if (pkt.messageAuthenticator) {
    if (!pkt.verifyMessageAuthenticator()) {
      console.error('Message-Authenticator verification failed');
      // Don't reply — silently drop the packet per RFC 3579
      return;
    }
  }
  // ... process the request
}

Accounting

Accounting packets track session lifecycle: Start, Interim-Update, Stop. The authenticator is computed (not random), so the server can verify the packet wasn't tampered with.

import { Client, Dictionary, AccountingResponse } from 'tsrad';

const dict = new Dictionary('/usr/share/freeradius/dictionary');
const client = new Client({
  server: '192.168.1.1',
  secret: Buffer.from('testing123'),
  dict,
});

// --- Session Start ---
const start = client.createAcctPacket();
start.addAttribute('User-Name', 'alice@example.com');
start.addAttribute('Acct-Status-Type', 'Start');
start.addAttribute('Acct-Session-Id', 'sess-00a1b2c3');
start.addAttribute('NAS-IP-Address', '10.0.0.1');
start.addAttribute('NAS-Port', 1);
start.addAttribute('Framed-IP-Address', '10.10.0.50');
start.addAttribute('Service-Type', 'Framed-User');

const startReply = await client.sendPacket(start);
console.log('Start acked:', startReply.code === AccountingResponse);

// --- Interim Update (5 minutes in) ---
const interim = client.createAcctPacket();
interim.addAttribute('User-Name', 'alice@example.com');
interim.addAttribute('Acct-Status-Type', 'Interim-Update');
interim.addAttribute('Acct-Session-Id', 'sess-00a1b2c3');
interim.addAttribute('NAS-IP-Address', '10.0.0.1');
interim.addAttribute('Acct-Session-Time', 300);
interim.addAttribute('Acct-Input-Octets', 1048576);      // 1 MB downloaded
interim.addAttribute('Acct-Output-Octets', 524288);       // 512 KB uploaded

const interimReply = await client.sendPacket(interim);
console.log('Interim acked:', interimReply.code === AccountingResponse);

// --- Session Stop ---
const stop = client.createAcctPacket();
stop.addAttribute('User-Name', 'alice@example.com');
stop.addAttribute('Acct-Status-Type', 'Stop');
stop.addAttribute('Acct-Session-Id', 'sess-00a1b2c3');
stop.addAttribute('NAS-IP-Address', '10.0.0.1');
stop.addAttribute('Acct-Session-Time', 3600);
stop.addAttribute('Acct-Input-Octets', 52428800);         // 50 MB
stop.addAttribute('Acct-Output-Octets', 10485760);        // 10 MB
stop.addAttribute('Acct-Terminate-Cause', 'User-Request');

const stopReply = await client.sendPacket(stop);
console.log('Stop acked:', stopReply.code === AccountingResponse);

client.close();

The client automatically increments Acct-Delay-Time on retries, so the server knows how stale the data is.

CoA (Change of Authorization)

CoA lets you push policy changes to a NAS for an active session — change bandwidth, apply filters, or update session parameters without disconnecting the user.

import { Client, Dictionary, CoAACK, CoANAK } from 'tsrad';

const dict = new Dictionary('/usr/share/freeradius/dictionary');
const client = new Client({
  server: '192.168.1.1',     // the NAS (not the RADIUS server)
  secret: Buffer.from('coa_secret'),
  dict,
  coaport: 3799,
});

const coa = client.createCoAPacket();
coa.addAttribute('User-Name', 'alice@example.com');
coa.addAttribute('Acct-Session-Id', 'sess-00a1b2c3');
// Push new bandwidth policy
coa.addAttribute('Filter-Id', 'premium-100mbps');

try {
  const reply = await client.sendPacket(coa);
  if (reply.code === CoAACK) {
    console.log('Policy change applied');
  } else if (reply.code === CoANAK) {
    console.log('NAS rejected the CoA');
    if (reply.has('Error-Cause')) {
      console.log('Error-Cause:', reply.getAttribute('Error-Cause')[0]);
    }
  }
} finally {
  client.close();
}

Disconnect Request

Force-disconnect a session from the NAS. Uses the same CoA port (3799) with a different packet code.

import {
  Client, Dictionary, CoAPacket,
  DisconnectRequest, DisconnectACK, DisconnectNAK,
} from 'tsrad';

const dict = new Dictionary('/usr/share/freeradius/dictionary');
const client = new Client({
  server: '192.168.1.1',
  secret: Buffer.from('coa_secret'),
  dict,
});

// DisconnectRequest is sent via createCoAPacket with explicit code
const disc = client.createCoAPacket({ code: DisconnectRequest });
disc.addAttribute('User-Name', 'alice@example.com');
disc.addAttribute('Acct-Session-Id', 'sess-00a1b2c3');
disc.addAttribute('NAS-IP-Address', '10.0.0.1');

const reply = await client.sendPacket(disc);
if (reply.code === DisconnectACK) {
  console.log('Session terminated');
} else if (reply.code === DisconnectNAK) {
  console.log('Disconnect refused');
}
client.close();

Vendor-Specific Attributes

Many network equipment vendors define their own RADIUS attributes inside the Vendor-Specific (type 26) wrapper. tsrad handles them transparently once the vendor is defined in the dictionary.

import { Client, Dictionary, AccessAccept } from 'tsrad';

// Dictionary with Mikrotik vendor definitions
const dict = new Dictionary('/path/to/dictionary.mikrotik');
const client = new Client({
  server: '192.168.1.1',
  secret: Buffer.from('testing123'),
  dict,
});

// Reading vendor attributes from a reply
const req = client.createAuthPacket();
req.addAttribute('User-Name', 'alice');
req.set('User-Password', [req.pwCrypt('password')]);

const reply = await client.sendPacket(req);
if (reply.code === AccessAccept) {
  // Vendor attributes are accessed by name, same as standard attributes
  if (reply.has('Mikrotik-Rate-Limit')) {
    console.log('Rate limit:', reply.getAttribute('Mikrotik-Rate-Limit')[0]);
    // e.g. '10M/10M' for 10 Mbps up/down
  }
}

client.close();

On the server side, set vendor attributes in replies:

handleAuthPacket(pkt: RadiusPacket) {
  const reply = this.createReplyPacket(pkt, { code: AccessAccept });

  // Standard attributes
  reply.addAttribute('Framed-IP-Address', '10.10.0.50');
  reply.addAttribute('Session-Timeout', 86400);

  // Mikrotik-specific rate limiting
  reply.addAttribute('Mikrotik-Rate-Limit', '50M/50M');
  reply.addAttribute('Mikrotik-Recv-Limit', 0);
  reply.addAttribute('Mikrotik-Xmit-Limit', 0);

  this.sendReply(reply);
}

Named values

When the dictionary defines VALUE mappings, you can use symbolic names instead of raw integers. This makes code self-documenting and less error-prone.

// Dictionary defines:
//   VALUE  Service-Type  Login-User   1
//   VALUE  Service-Type  Framed-User  2

// Set by name
pkt.addAttribute('Service-Type', 'Framed-User');

// Read back — returns the name, not the number
pkt.getAttribute('Service-Type');  // ['Framed-User']

// You can also use the raw integer
pkt.addAttribute('Service-Type', 2);

// Dictionary VALUE mappings for common attributes:
//   VALUE Acct-Status-Type  Start          1
//   VALUE Acct-Status-Type  Stop           2
//   VALUE Acct-Status-Type  Interim-Update 3
pkt.addAttribute('Acct-Status-Type', 'Start');

//   VALUE Acct-Terminate-Cause  User-Request   1
//   VALUE Acct-Terminate-Cause  Lost-Carrier   2
//   VALUE Acct-Terminate-Cause  Idle-Timeout   4
//   VALUE Acct-Terminate-Cause  Session-Timeout 5
pkt.addAttribute('Acct-Terminate-Cause', 'User-Request');

Tagged attributes (RFC 2868)

Tunnel attributes use tags to group related attributes. For example, a NAS can establish multiple tunnels — tag 1 for the first, tag 2 for the second.

// Set tagged attributes using "Attribute-Name:tag" syntax
pkt.addAttribute('Tunnel-Type:1', 'L2TP');
pkt.addAttribute('Tunnel-Medium-Type:1', 'IPv4');
pkt.addAttribute('Tunnel-Server-Endpoint:1', '10.0.0.1');

// Second tunnel
pkt.addAttribute('Tunnel-Type:2', 'GRE');
pkt.addAttribute('Tunnel-Medium-Type:2', 'IPv4');
pkt.addAttribute('Tunnel-Server-Endpoint:2', '10.0.0.2');

Salt encryption (RFC 2868)

Tunnel-Password and similar sensitive attributes use salt encryption — a per-attribute random salt combined with MD5-based encryption. This is handled automatically for attributes with encrypt=2 in the dictionary, but you can also use it manually:

// Manual salt encrypt/decrypt
const encrypted = pkt.saltCrypt('my-tunnel-password');
// encrypted = [2-byte salt] + [encrypted data]

const decrypted = pkt.saltDecrypt(encrypted);
// decrypted.toString() === 'my-tunnel-password'

TLV (Type-Length-Value) sub-attributes

Some vendors use TLV nesting — a parent attribute that contains sub-attributes. This is common in WiMAX and some newer vendor extensions.

// Dictionary defines:
//   ATTRIBUTE WiMAX-Capability    1 tlv
//   ATTRIBUTE WiMAX-Release     1.1 string
//   ATTRIBUTE WiMAX-Accounting  1.2 integer

// Add sub-attributes — they auto-nest under the parent
pkt.addAttribute('WiMAX-Release', '2.1');
pkt.addAttribute('WiMAX-Accounting', 1);

// Read the parent TLV — returns a structured object
const tlv = pkt.getAttribute('WiMAX-Capability');
// [{
//   'WiMAX-Release': ['2.1'],
//   'WiMAX-Accounting': [1],
// }]

Constructing packets with attributes inline

You can pass attributes directly in the packet constructor using underscore-separated names:

const pkt = new AuthPacket({
  secret: Buffer.from('testing123'),
  dict,
  User_Name: 'alice',
  NAS_IP_Address: '10.0.0.1',
  NAS_Port: 1,
  Service_Type: 'Framed-User',
});

Underscores in the key are converted to hyphens, so User_Name becomes User-Name.

Low-level packet access

For attributes not in the dictionary, or when you need raw Buffer access:

// Get/set by numeric attribute code
pkt.set(1, [Buffer.from('alice')]);           // User-Name
const raw = pkt.get(1);                        // [Buffer<616c696365>]

// Vendor attributes use tuple keys: [vendorId, attrCode]
pkt.set([14988, 8] as [number, number], [Buffer.from('50M/50M')]);
const vraw = pkt.get([14988, 8] as [number, number]);

// List all attribute keys
pkt.keys();  // ['User-Name', 'NAS-IP-Address', ...]

Password encryption internals

RFC 2865 section 5.2 defines User-Password encryption:

b1 = MD5(secret + authenticator)
c1 = p1 XOR b1

b2 = MD5(secret + c1)
c2 = p2 XOR b2
...

Where p1, p2, ... are 16-byte blocks of the password (zero-padded).

// Encrypt — requires authenticator to be set
const encrypted = pkt.pwCrypt('mypassword');
// Returns a Buffer that can be set as User-Password

// Decrypt — uses the same authenticator and secret
const password = pkt.pwDecrypt(encrypted);
// Returns the original string

// Long passwords (>16 bytes) are handled automatically
const longEncrypted = pkt.pwCrypt('this-is-a-very-long-password-that-spans-multiple-blocks');
const longDecrypted = pkt.pwDecrypt(longEncrypted);

Verifying reply packets

When you receive a reply, verify the authenticator matches what you expect:

const req = client.createAuthPacket();
// ... add attributes ...
const reply = await client.sendPacket(req);

// sendPacket does this automatically, but for manual UDP:
const isValid = req.verifyReply(reply);
// Checks: MD5(reply.code + reply.id + reply.length + request.authenticator + reply.attrs + secret)

For accounting packets:

const acct = new AcctPacket({
  secret: Buffer.from('testing123'),
  packet: rawUdpData,
});
const isValid = acct.verifyAcctRequest();
// Checks: MD5(code + id + length + zeros + attrs + secret)

Building a RADIUS server

The server uses a subclass pattern. Override handler methods to implement your authentication and accounting logic.

Minimal auth server

import {
  Server, RemoteHost, Dictionary,
  AccessAccept, AccessReject,
  type RadiusPacket,
} from 'tsrad';

const dict = new Dictionary('/usr/share/freeradius/dictionary');

class SimpleAuthServer extends Server {
  handleAuthPacket(pkt: RadiusPacket) {
    const username = pkt.getAttribute('User-Name')[0] as string;
    const encrypted = pkt.getAttribute('User-Password')[0] as Buffer;
    const password = pkt.pwDecrypt(encrypted);

    const code = (username === 'admin' && password === 'admin')
      ? AccessAccept
      : AccessReject;

    const reply = this.createReplyPacket(pkt, { code });
    this.sendReply(reply);
  }
}

const server = new SimpleAuthServer({
  addresses: ['0.0.0.0'],
  dict,
  hosts: new Map([
    ['0.0.0.0', new RemoteHost('0.0.0.0', Buffer.from('testing123'), 'any')],
  ]),
});

server.on('ready', () => console.log('Listening on :1812'));
server.on('error', (err) => console.error(err.message));
server.run();

ISP subscriber management server

A more realistic example with a user database, session tracking, and accounting:

import {
  Server, RemoteHost, Dictionary,
  AccessAccept, AccessReject, AccountingResponse,
  type RadiusPacket,
} from 'tsrad';

const dict = new Dictionary(
  '/usr/share/freeradius/dictionary.rfc2865',
  '/usr/share/freeradius/dictionary.rfc2866',
  '/usr/share/freeradius/dictionary.mikrotik',
);

// --- Subscriber database ---
interface Subscriber {
  password: string;
  plan: string;
  ip: string;
  rateLimit: string;
  sessionTimeout: number;
}

const subscribers = new Map<string, Subscriber>([
  ['alice@isp.net', {
    password: 'alice123',
    plan: 'home-50',
    ip: '10.10.1.10',
    rateLimit: '50M/50M',
    sessionTimeout: 86400,
  }],
  ['bob@isp.net', {
    password: 'bob456',
    plan: 'business-100',
    ip: '10.10.2.20',
    rateLimit: '100M/100M',
    sessionTimeout: 0,  // no timeout
  }],
]);

// --- Active sessions ---
interface Session {
  username: string;
  nasIp: string;
  nasPort: number;
  startTime: Date;
  inputOctets: number;
  outputOctets: number;
}

const sessions = new Map<string, Session>();

// --- Server ---
class ISPServer extends Server {
  handleAuthPacket(pkt: RadiusPacket) {
    const username = pkt.getAttribute('User-Name')[0] as string;
    const encrypted = pkt.getAttribute('User-Password')[0] as Buffer;
    const password = pkt.pwDecrypt(encrypted);
    const nasIp = pkt.source.address;

    console.log(`[AUTH] ${username} from NAS ${nasIp}`);

    const sub = subscribers.get(username);
    if (!sub || sub.password !== password) {
      console.log(`[AUTH] ${username} REJECTED`);
      const reply = this.createReplyPacket(pkt, { code: AccessReject });
      reply.addAttribute('Reply-Message', 'Invalid username or password');
      this.sendReply(reply);
      return;
    }

    console.log(`[AUTH] ${username} ACCEPTED (plan: ${sub.plan})`);
    const reply = this.createReplyPacket(pkt, { code: AccessAccept });
    reply.addAttribute('Framed-IP-Address', sub.ip);
    reply.addAttribute('Framed-IP-Netmask', '255.255.255.0');
    reply.addAttribute('Service-Type', 'Framed-User');
    reply.addAttribute('Framed-Protocol', 'PPP');

    if (sub.sessionTimeout > 0) {
      reply.addAttribute('Session-Timeout', sub.sessionTimeout);
    }

    // Mikrotik-specific rate limiting
    reply.addAttribute('Mikrotik-Rate-Limit', sub.rateLimit);

    this.sendReply(reply);
  }

  handleAcctPacket(pkt: RadiusPacket) {
    const username = pkt.getAttribute('User-Name')[0] as string;
    const statusType = pkt.getAttribute('Acct-Status-Type')[0] as string;
    const sessionId = pkt.getAttribute('Acct-Session-Id')[0] as string;

    console.log(`[ACCT] ${username} ${statusType} (session: ${sessionId})`);

    switch (statusType) {
      case 'Start': {
        sessions.set(sessionId, {
          username,
          nasIp: pkt.getAttribute('NAS-IP-Address')[0] as string,
          nasPort: pkt.getAttribute('NAS-Port')[0] as number,
          startTime: new Date(),
          inputOctets: 0,
          outputOctets: 0,
        });
        break;
      }

      case 'Interim-Update': {
        const session = sessions.get(sessionId);
        if (session) {
          session.inputOctets = pkt.getAttribute('Acct-Input-Octets')[0] as number;
          session.outputOctets = pkt.getAttribute('Acct-Output-Octets')[0] as number;
        }
        break;
      }

      case 'Stop': {
        const session = sessions.get(sessionId);
        if (session) {
          const duration = pkt.getAttribute('Acct-Session-Time')[0] as number;
          const input = pkt.getAttribute('Acct-Input-Octets')[0] as number;
          const output = pkt.getAttribute('Acct-Output-Octets')[0] as number;
          const cause = pkt.has('Acct-Terminate-Cause')
            ? pkt.getAttribute('Acct-Terminate-Cause')[0] as string
            : 'Unknown';

          console.log(
            `[ACCT] Session ended: ${username}, ` +
            `duration=${duration}s, ` +
            `in=${(input / 1048576).toFixed(1)}MB, ` +
            `out=${(output / 1048576).toFixed(1)}MB, ` +
            `cause=${cause}`
          );
          sessions.delete(sessionId);
        }
        break;
      }
    }

    // Always acknowledge accounting packets
    const reply = this.createReplyPacket(pkt, { code: AccountingResponse });
    this.sendReply(reply);
  }
}

// --- NAS definitions ---
const hosts = new Map<string, RemoteHost>([
  ['10.0.0.1', new RemoteHost('10.0.0.1', Buffer.from('nas1_secret'), 'core-router')],
  ['10.0.0.2', new RemoteHost('10.0.0.2', Buffer.from('nas2_secret'), 'access-switch')],
  // Wildcard fallback for development
  ['0.0.0.0', new RemoteHost('0.0.0.0', Buffer.from('testing123'), 'any')],
]);

const server = new ISPServer({
  addresses: ['0.0.0.0'],
  dict,
  hosts,
  authEnabled: true,
  acctEnabled: true,
  coaEnabled: false,
});

server.on('ready', () => {
  console.log('ISP RADIUS server running');
  console.log('  Auth: :1812');
  console.log('  Acct: :1813');
});

server.on('error', (err) => {
  console.error('[ERROR]', err.message);
});

server.run();

CoA/Disconnect server

To receive CoA and Disconnect-Request packets from a management system, enable coaEnabled:

import {
  Server, RemoteHost, Dictionary,
  CoAACK, CoANAK, DisconnectACK, DisconnectNAK,
  type RadiusPacket,
} from 'tsrad';

const dict = new Dictionary('/usr/share/freeradius/dictionary');

class CoAServer extends Server {
  handleCoaPacket(pkt: RadiusPacket) {
    const username = pkt.getAttribute('User-Name')[0] as string;
    const sessionId = pkt.getAttribute('Acct-Session-Id')[0] as string;

    console.log(`[CoA] Change request for ${username} (session: ${sessionId})`);

    // Apply the policy change (implementation depends on your NAS)
    const success = this.applyPolicyChange(username, sessionId, pkt);

    const code = success ? CoAACK : CoANAK;
    const reply = this.createReplyPacket(pkt, { code });
    this.sendReply(reply);
  }

  handleDisconnectPacket(pkt: RadiusPacket) {
    const username = pkt.getAttribute('User-Name')[0] as string;
    const sessionId = pkt.getAttribute('Acct-Session-Id')[0] as string;

    console.log(`[DISCONNECT] Terminating ${username} (session: ${sessionId})`);

    const success = this.terminateSession(username, sessionId);

    const code = success ? DisconnectACK : DisconnectNAK;
    const reply = this.createReplyPacket(pkt, { code });
    this.sendReply(reply);
  }

  private applyPolicyChange(username: string, sessionId: string, pkt: RadiusPacket): boolean {
    // Your NAS-specific logic here
    return true;
  }

  private terminateSession(username: string, sessionId: string): boolean {
    // Your session termination logic here
    return true;
  }
}

const server = new CoAServer({
  addresses: ['0.0.0.0'],
  dict,
  hosts: new Map([
    ['0.0.0.0', new RemoteHost('0.0.0.0', Buffer.from('coa_secret'), 'any')],
  ]),
  authEnabled: false,   // this server only handles CoA
  acctEnabled: false,
  coaEnabled: true,
});

server.on('ready', () => console.log('CoA server on :3799'));
server.run();

Multiple NAS with per-host secrets

In production, each NAS has its own shared secret. The server looks up the secret by the source IP of incoming packets.

const hosts = new Map<string, RemoteHost>([
  // Core routers
  ['10.0.0.1', new RemoteHost('10.0.0.1', Buffer.from('r1$ecret!'), 'core-router-1')],
  ['10.0.0.2', new RemoteHost('10.0.0.2', Buffer.from('r2$ecret!'), 'core-router-2')],

  // Access switches
  ['10.1.0.1', new RemoteHost('10.1.0.1', Buffer.from('sw1$ecret'), 'access-sw-1')],
  ['10.1.0.2', new RemoteHost('10.1.0.2', Buffer.from('sw2$ecret'), 'access-sw-2')],

  // Wireless controllers
  ['10.2.0.1', new RemoteHost('10.2.0.1', Buffer.from('wlc$ecret'), 'wireless-ctrl')],

  // NO wildcard 0.0.0.0 — reject packets from unknown sources
]);

const server = new MyServer({
  addresses: ['10.0.0.254'],  // bind to management VLAN IP
  dict,
  hosts,
});

When a packet arrives from an IP not in the hosts map and there's no 0.0.0.0 fallback, the server emits an error event and drops the packet.

Database-backed auth and accounting

tsrad includes a database integration module that implements FreeRADIUS rlm_sql compatible schema. This lets you authenticate users and record accounting data in any SQL database supported by knex (PostgreSQL, MySQL, SQLite, MSSQL).

If you have an existing FreeRADIUS database, tsrad works with it out of the box — same tables, same schema.

Install dependencies

knex is a peer dependency. Install it along with your database driver:

# PostgreSQL
npm install knex pg

# MySQL
npm install knex mysql2

# SQLite (for development/testing)
npm install knex better-sqlite3

Initialize the database

import knex from 'knex';
import { createSchema, dropSchema } from 'tsrad';

const db = knex({
  client: 'pg',
  connection: {
    host: '127.0.0.1',
    port: 5432,
    user: 'radius',
    password: 'radpass',
    database: 'radius',
  },
});

// Create the FreeRADIUS-compatible schema (idempotent — safe to call multiple times)
await createSchema(db);

// Tables created:
//   radcheck        — per-user check attributes (password, auth conditions)
//   radreply        — per-user reply attributes (IP, timeout, etc.)
//   radusergroup    — user-to-group membership with priority
//   radgroupcheck   — per-group check attributes
//   radgroupreply   — per-group reply attributes
//   radacct         — accounting records (session start/stop/interim)
//   nas             — NAS client definitions

Seed users

// Add a user with cleartext password
await db('radcheck').insert({
  username: 'alice@isp.net',
  attribute: 'Cleartext-Password',
  op: ':=',
  value: 'secret123',
});

// Add reply attributes (returned in Access-Accept)
await db('radreply').insert([
  { username: 'alice@isp.net', attribute: 'Framed-IP-Address', op: ':=', value: '10.10.1.10' },
  { username: 'alice@isp.net', attribute: 'Session-Timeout', op: ':=', value: '86400' },
  { username: 'alice@isp.net', attribute: 'Reply-Message', op: ':=', value: 'Welcome Alice!' },
]);

// Add check conditions (beyond password)
await db('radcheck').insert({
  username: 'alice@isp.net',
  attribute: 'NAS-Port',
  op: '>=',
  value: '1',
});

Set up groups

// Create group membership
await db('radusergroup').insert([
  { username: 'alice@isp.net', groupname: 'residential', priority: 1 },
  { username: 'alice@isp.net', groupname: 'premium', priority: 2 },
]);

// Group check attributes (conditions all group members must pass)
await db('radgroupcheck').insert({
  groupname: 'premium',
  attribute: 'NAS-Port',
  op: '>=',
  value: '1',
});

// Group reply attributes (added to Access-Accept for group members)
await db('radgroupreply').insert([
  { groupname: 'residential', attribute: 'Filter-Id', op: ':=', value: 'std-50mbps' },
  { groupname: 'premium', attribute: 'Filter-Id', op: ':=', value: 'premium-100mbps' },
]);

Quick start: DatabaseServer

The simplest way to run a database-backed RADIUS server:

import knex from 'knex';
import { DatabaseServer, RemoteHost, Dictionary, createSchema } from 'tsrad';

const dict = new Dictionary('/usr/share/freeradius/dictionary');
const db = knex({
  client: 'pg',
  connection: 'postgres://radius:radpass@localhost/radius',
});

await createSchema(db);

const server = new DatabaseServer({
  addresses: ['0.0.0.0'],
  dict,
  knex: db,
  groups: true,  // enable group-based auth (default: true)
  hosts: new Map([
    ['10.0.0.1', new RemoteHost('10.0.0.1', Buffer.from('nas1_secret'), 'core-router')],
    ['0.0.0.0', new RemoteHost('0.0.0.0', Buffer.from('testing123'), 'any')],
  ]),
});

server.on('ready', () => {
  console.log('Database RADIUS server running');
  console.log('  Auth: :1812');
  console.log('  Acct: :1813');
});
server.on('error', (err) => console.error(err.message));
server.run();

This gives you:

  • Auth: PAP and CHAP verification against radcheck, reply attrs from radreply, group-based auth via radusergroup/radgroupcheck/radgroupreply
  • Acct: Session tracking in radacct (Start inserts, Interim-Update updates counters, Stop records stop time and terminate cause)

Handler factories: composing with your own server

If you need more control, use the handler factories directly. They return functions compatible with the Server handler signature:

import { Server, RemoteHost, Dictionary, createDbAuth, createDbAcct } from 'tsrad';
import type { RadiusPacket } from 'tsrad';

const dict = new Dictionary('/usr/share/freeradius/dictionary');

class MyServer extends Server {
  constructor(db: Knex) {
    super({
      addresses: ['0.0.0.0'],
      dict,
      hosts: new Map([
        ['0.0.0.0', new RemoteHost('0.0.0.0', Buffer.from('secret'), 'any')],
      ]),
    });

    // Wire up database handlers
    this.handleAuthPacket = createDbAuth({
      knex: db,
      groups: true,        // check radusergroup + radgroupcheck + radgroupreply
      // Optional: custom password verifier
      // verifyPassword: (pkt, dbPassword) => myCustomCheck(pkt, dbPassword),
    });

    this.handleAcctPacket = createDbAcct({
      knex: db,
    });
  }
}

You can combine this with middleware for cross-cutting concerns:

const server = new MyServer(db);

// Middleware runs before the DB handler
server.useAuth(async (ctx, next) => {
  console.log(`Auth request from ${ctx.source.address}`);
  const start = Date.now();
  await next(ctx);
  console.log(`Auth completed in ${Date.now() - start}ms`);
});

server.run();

Query helpers for custom handlers

All query functions are exported for building custom authentication logic:

import {
  findUser, findUserReply, findUserGroups,
  findGroupCheck, findGroupReply,
  evaluateOp,
} from 'tsrad';

// In a custom handler
async function myAuthHandler(this: Server, pkt: RadiusPacket) {
  const username = pkt.getAttribute('User-Name')[0] as string;

  // Get check attributes from radcheck
  const checks = await findUser(db, username);

  // Get reply attributes from radreply
  const replyAttrs = await findUserReply(db, username);

  // Get user's groups (ordered by priority)
  const groups = await findUserGroups(db, username);

  // For each group, get check and reply attributes
  for (const group of groups) {
    const groupChecks = await findGroupCheck(db, group.groupname);
    const groupReplies = await findGroupReply(db, group.groupname);
    // ... your custom logic
  }

  // Evaluate check operators
  evaluateOp(':=', 'alice', 'alice');     // true  (equals)
  evaluateOp('!=', 'alice', 'bob');       // true  (not equals)
  evaluateOp('>=', '10', '5');            // true  (numeric >=)
  evaluateOp('=~', 'alice', 'ali.*');     // true  (regex match)
  evaluateOp('+=', 'any', 'thing');       // true  (always passes — adds to reply)
}

Check attribute operators

The op column in radcheck and radgroupcheck controls how attribute values are compared:

Operator Meaning Example
:= Set/equals (assign value, or match exactly) Cleartext-Password := secret
== Equals NAS-IP-Address == 10.0.0.1
!= Not equals NAS-Port != 0
>= Greater than or equal (numeric) NAS-Port >= 1
> Greater than (numeric) Session-Timeout > 0
<= Less than or equal (numeric) NAS-Port <= 48
< Less than (numeric) Acct-Session-Time < 86400
=~ Regex match Calling-Station-Id =~ ^aa:bb:.*
!~ Regex non-match User-Name !~ @banned.com$
+= Append (always passes as check, adds to reply) Reply-Message += Extra info

Auth flow (how createDbAuth works)

  1. Extract User-Name from the incoming packet
  2. Query radcheck for all rows matching this username
  3. If no rows found → Access-Reject
  4. Find the Cleartext-Password row (op := or ==) and verify:
    • PAP: decrypt User-Password attribute via pwDecrypt(), compare to DB value
    • CHAP: verify via verifyChapPasswd() using the DB password
  5. Evaluate remaining check attributes using their operators
  6. On pass: query radreply for reply attributes
  7. If groups enabled: query radusergroup → for each group, check radgroupcheck, collect radgroupreply
  8. Build Access-Accept with all collected reply attributes
  9. On any failure: Access-Reject

Acct flow (how createDbAcct works)

  1. Extract Acct-Status-Type, Acct-Session-Id, User-Name from packet
  2. Start → INSERT new row into radacct with session start time, NAS info, etc.
  3. Interim-Update → UPDATE radacct with current counters (Acct-Input-Octets, Acct-Output-Octets, Acct-Session-Time)
  4. Stop → UPDATE radacct with stop time, final counters, and Acct-Terminate-Cause
  5. Always send Accounting-Response

Using SQLite for development

SQLite is ideal for local development and testing:

import knex from 'knex';
import { DatabaseServer, RemoteHost, Dictionary, createSchema } from 'tsrad';

const db = knex({
  client: 'better-sqlite3',
  connection: { filename: './radius.db' },
  useNullAsDefault: true,
});

await createSchema(db);

// Seed a test user
await db('radcheck').insert({
  username: 'test', attribute: 'Cleartext-Password', op: ':=', value: 'test',
});

const dict = new Dictionary('/usr/share/freeradius/dictionary');
const server = new DatabaseServer({
  addresses: ['127.0.0.1'],
  dict,
  knex: db,
  hosts: new Map([
    ['0.0.0.0', new RemoteHost('0.0.0.0', Buffer.from('testing123'), 'any')],
  ]),
});

server.on('ready', () => console.log('Dev server on :1812/:1813'));
server.run();

// Test with radtest or any RADIUS client:
//   radtest test test 127.0.0.1 0 testing123

Migrating from FreeRADIUS

If you already have a FreeRADIUS database with rlm_sql, tsrad works with it directly — no migration needed. Just point knex at the same database:

const db = knex({
  client: 'mysql2',
  connection: {
    host: 'db.example.com',
    user: 'radius',
    password: 'radpass',
    database: 'radius',
  },
});

// Don't call createSchema() — your tables already exist
const server = new DatabaseServer({
  addresses: ['0.0.0.0'],
  dict,
  knex: db,
  hosts: new Map([...]),
});
server.run();

Timeout handling

import { Client, Timeout } from 'tsrad';

const client = new Client({
  server: '192.168.1.1',
  secret: Buffer.from('testing123'),
  dict,
  timeout: 2,    // 2 seconds per attempt
  retries: 3,    // 3 retries = 4 total attempts = 8 seconds max
});

try {
  const reply = await client.sendPacket(req);
} catch (err) {
  if (err instanceof Timeout) {
    console.error('RADIUS server unreachable after 4 attempts (8 seconds)');
  } else {
    throw err;  // unexpected error
  }
} finally {
  client.close();
}

Complete client-server round-trip example

A self-contained example that starts a server and sends a request to it:

import {
  Server, Client, RemoteHost, Dictionary,
  AccessAccept, AccessReject, AccountingResponse,
  type RadiusPacket,
} from 'tsrad';

const dict = new Dictionary('/usr/share/freeradius/dictionary');

// --- Server ---
class TestServer extends Server {
  handleAuthPacket(pkt: RadiusPacket) {
    const username = pkt.getAttribute('User-Name')[0] as string;
    const password = pkt.pwDecrypt(pkt.getAttribute('User-Password')[0] as Buffer);

    const code = (username === 'test' && password === 'test')
      ? AccessAccept : AccessReject;

    const reply = this.createReplyPacket(pkt, { code });
    if (code === AccessAccept) {
      reply.addAttribute('Reply-Message', 'Welcome, ' + username);
      reply.addAttribute('Session-Timeout', 3600);
    }
    this.sendReply(reply);
  }

  handleAcctPacket(pkt: RadiusPacket) {
    const reply = this.createReplyPacket(pkt, { code: AccountingResponse });
    this.sendReply(reply);
  }
}

const server = new TestServer({
  addresses: ['127.0.0.1'],
  dict,
  hosts: new Map([
    ['127.0.0.1', new RemoteHost('127.0.0.1', Buffer.from('secret'), 'localhost')],
  ]),
});

server.on('ready', async () => {
  console.log('Server ready');

  // --- Client ---
  const client = new Client({
    server: '127.0.0.1',
    secret: Buffer.from('secret'),
    dict,
    timeout: 2,
    retries: 1,
  });

  // Auth request
  const req = client.createAuthPacket();
  req.addAttribute('User-Name', 'test');
  req.set('User-Password', [req.pwCrypt('test')]);

  const reply = await client.sendPacket(req);
  console.log('Auth reply code:', reply.code, reply.code === AccessAccept ? '(Accept)' : '(Reject)');
  if (reply.has('Reply-Message')) {
    console.log('Reply-Message:', reply.getAttribute('Reply-Message')[0]);
  }
  if (reply.has('Session-Timeout')) {
    console.log('Session-Timeout:', reply.getAttribute('Session-Timeout')[0]);
  }

  // Accounting request
  const acct = client.createAcctPacket();
  acct.addAttribute('User-Name', 'test');
  acct.addAttribute('Acct-Status-Type', 'Start');
  acct.addAttribute('Acct-Session-Id', 'test-session-001');

  const acctReply = await client.sendPacket(acct);
  console.log('Acct reply code:', acctReply.code, '(AccountingResponse)');

  client.close();
  server.stop();
  console.log('Done');
});

server.run();

Data type reference

Quick reference for encoding and decoding attribute values:

import {
  encodeAttr, decodeAttr,
  encodeString, encodeAddress, encodeInteger, encodeInteger64,
  encodeDate, encodeOctets, encodeIPv6Address, encodeIPv6Prefix,
  encodeAscendBinary,
} from 'tsrad';

// string
encodeAttr('string', 'hello');                    // Buffer<68656c6c6f>
decodeAttr('string', Buffer.from('hello'));        // 'hello'

// ipaddr (IPv4)
encodeAttr('ipaddr', '192.168.1.1');              // Buffer<c0a80101>
decodeAttr('ipaddr', Buffer.from([192,168,1,1])); // '192.168.1.1'

// integer (uint32)
encodeAttr('integer', 42);                        // Buffer<0000002a>
decodeAttr('integer', Buffer.from([0,0,0,42]));   // 42

// integer64 (uint64)
encodeAttr('integer64', 9007199254740993n);        // Buffer<0020000000000001>
decodeAttr('integer64', Buffer.alloc(8));           // 0n

// date (unix timestamp as uint32)
encodeAttr('date', 1700000000);                    // Buffer<6554b5c0> (approx)
decodeAttr('date', Buffer.from([0x65,0x54,0xb5,0xc0])); // 1700000000

// octets (raw bytes)
encodeAttr('octets', '0xdeadbeef');               // Buffer<deadbeef>
encodeAttr('octets', Buffer.from([0xca, 0xfe]));  // Buffer<cafe>

// ipv6addr
encodeAttr('ipv6addr', '2001:db8::1');            // 16-byte Buffer
decodeAttr('ipv6addr', Buffer.alloc(16));          // '0000:0000:...:0000'

// ipv6prefix
encodeAttr('ipv6prefix', '2001:db8::/32');         // 18-byte Buffer
decodeAttr('ipv6prefix', Buffer.alloc(18));         // 'addr/prefix'

// signed (int32)
encodeAttr('signed', -1);                          // Buffer<ffffffff>

// short (uint16)
encodeAttr('short', 1024);                         // Buffer<0400>

// byte (uint8)
encodeAttr('byte', 255);                           // Buffer<ff>

// abinary (Ascend filter)
encodeAttr('abinary', 'family=ipv4 action=accept direction=in src=10.0.0.0/24');
// 56-byte Ascend binary filter

Supported RFCs

RFC Description tsrad support
RFC 2865 RADIUS Authentication Full: Access-Request/Accept/Reject/Challenge, User-Password encryption, CHAP verification
RFC 2866 RADIUS Accounting Full: Accounting-Request/Response, authenticator verification, Acct-Delay-Time
RFC 2868 RADIUS Tunnel Attributes Full: tagged attributes, salt encryption (encrypt=2)
RFC 3576 Dynamic Authorization (CoA) Full: CoA-Request/ACK/NAK, Disconnect-Request/ACK/NAK
RFC 3579 RADIUS EAP Support Partial: Message-Authenticator (HMAC-MD5) verification

License

BSD-3-Clause

About

A TypeScript RADIUS client/server library. Complete port of pyrad to idiomatic TypeScript. Implements RFC 2865 (Authentication), RFC 2866 (Accounting), RFC 2868 (Tunnel Attributes), and RFC 3576 (Dynamic Authorization / CoA).

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors