11 — RADIUS System¶
System: PyroRadius ISP Billing & RADIUS Management
Stack: FreeRADIUS 3.x · PostgreSQL 16 · Laravel 13
Last Updated: 2026-07-13
Overview¶
PyroRadius integrates with FreeRADIUS using the tables-as-API pattern. FreeRADIUS is configured with rlm_sql (the SQL module) to read authentication and authorisation data from a shared PostgreSQL database. PyroRadius writes to these tables to provision, modify, or remove customer access. FreeRADIUS reads them at connection time — there is no direct network communication between PyroRadius and FreeRADIUS.
This design is simple, reliable, and decoupled: the billing system can update RADIUS data at any time without FreeRADIUS needing to be restarted or notified. FreeRADIUS picks up changes on the next authentication or re-authentication.
RADIUS Database Tables¶
All RADIUS tables live on the same PostgreSQL instance as the main application, in a separate database (freeradius). The Laravel application uses a second database connection (radius) configured in config/database.php.
radcheck — Authentication Credentials¶
Stores the username and password for each active customer.
| Column | Type | Description |
|---|---|---|
id |
bigserial | Primary key |
username |
varchar(64) | PPPoE/RADIUS username |
attribute |
varchar(64) | Always Cleartext-Password in PyroRadius |
op |
varchar(2) | Always := (assign) |
value |
varchar(253) | Plaintext password |
FreeRADIUS reads this table at authentication time. If the username is present with a matching Cleartext-Password, authentication succeeds.
radusergroup — Username → Group Mapping¶
Maps each username to a group name that defines their speed profile.
| Column | Type | Description |
|---|---|---|
id |
bigserial | Primary key |
username |
varchar(64) | PPPoE username |
groupname |
varchar(64) | Package group name (e.g. pkg_10mbps_basic) |
priority |
integer | Priority when a user is in multiple groups (lower = higher priority) |
radgroupreply — Group → Speed Attributes¶
Defines the RADIUS reply attributes for each package group. FreeRADIUS sends these attributes in the Access-Accept response, telling the NAS device what speed limits to apply.
| Column | Type | Description |
|---|---|---|
id |
bigserial | Primary key |
groupname |
varchar(64) | Package group name |
attribute |
varchar(64) | RADIUS attribute name |
op |
varchar(2) | Always = |
value |
varchar(253) | Attribute value (speed string, policy name, etc.) |
radacct — Session Accounting¶
Written by FreeRADIUS (not by PyroRadius directly). Records all PPPoE session starts, stops, and interim updates.
| Column | Type | Description |
|---|---|---|
radacctid |
bigserial | Primary key |
acctsessionid |
varchar(64) | NAS-assigned session ID |
username |
varchar(64) | PPPoE username |
nasipaddress |
inet | NAS IP address |
framedipaddress |
inet | Customer's assigned IP |
callingstationid |
varchar(50) | MAC address (PPPoE) |
acctstarttime |
timestamptz | Session start |
acctstoptime |
timestamptz | Session end (NULL if still online) |
acctinputoctets |
bigint | Bytes received by NAS from customer (customer upload) |
acctoutputoctets |
bigint | Bytes sent by NAS to customer (customer download) |
acctterminatecause |
varchar(32) | Why session ended (e.g. User-Request, NAS-Error) |
Authentication Flow¶
PPPoE Client (CPE/ONT)
│
│ PPPoE LCP + CHAP/PAP
▼
NAS Device (MikroTik / Cisco / Juniper)
│
│ RADIUS Access-Request
│ (username, password, NAS-IP, NAS-Port)
▼
FreeRADIUS (rlm_sql module)
│
├── Query radcheck:
│ SELECT value FROM radcheck
│ WHERE username = ? AND attribute = 'Cleartext-Password';
│
├── Compare password → if match: continue; else: Access-Reject
│
├── Query radusergroup:
│ SELECT groupname FROM radusergroup WHERE username = ?;
│ → groupname = 'pkg_10mbps_basic'
│
├── Query radgroupreply:
│ SELECT attribute, op, value FROM radgroupreply
│ WHERE groupname = 'pkg_10mbps_basic';
│ → Mikrotik-Rate-Limit = '10M/5M'
│
└── Send Access-Accept to NAS
with reply attributes (speed limits, IP pool, session timeout)
│
▼
NAS applies rate limit to PPPoE session
NAS sends Accounting-Start to FreeRADIUS
│
▼
FreeRADIUS inserts row into radacct (acctstoptime = NULL)
Accounting Flow¶
NAS → FreeRADIUS: Accounting-Request (Start)
→ INSERT INTO radacct (..., acctstoptime = NULL)
NAS → FreeRADIUS: Accounting-Request (Interim-Update) [every ~5 minutes]
→ UPDATE radacct SET acctinputoctets = ?, acctoutputoctets = ?
WHERE acctsessionid = ? AND username = ?
NAS → FreeRADIUS: Accounting-Request (Stop)
→ UPDATE radacct SET acctstoptime = NOW(), acctterminatecause = ?
WHERE acctsessionid = ? AND username = ?
PyroRadius queries radacct (read-only) for:
- Online customer count (rows where acctstoptime IS NULL)
- Session duration and data usage on the customer show page
- NAS traffic statistics (aggregated octets per NAS)
- CloseGhostSessions maintenance (fixing stuck rows)
RADIUS Attribute Scheme Per Vendor¶
PyroRadius supports four NAS vendor types. The vendor is configured per-NAS in the NAS management UI (nas.vendor column). RadiusService selects the correct attributes based on this.
MikroTik (RouterOS)¶
Mikrotik-Rate-Limit = "10M/5M"
│ │ │
│ │ └── Upload limit (customer → internet)
│ └── Download limit (internet → customer)
└── Vendor-specific attribute
MikroTik applies the rate limit directly on the PPPoE interface using its built-in queuing system. No separate policy configuration is needed on the router.
Full radgroupreply entries for a MikroTik NAS:
groupname | attribute | value
---------------------|------------------------|------------------
pkg_10mbps_basic | Mikrotik-Rate-Limit | 10M/5M
pkg_10mbps_basic | Session-Timeout | 86400
Cisco IOS¶
Cisco uses the Cisco-AVPair VSA and WISPr-Bandwidth-Max-Down/Up standard attributes:
groupname | attribute | value
------------------|-------------------------------------|------------------
pkg_10mbps_basic | Cisco-AVPair | sub-qos-policy-in=PKG_10M_DOWN
pkg_10mbps_basic | Cisco-AVPair | sub-qos-policy-out=PKG_10M_UP
pkg_10mbps_basic | WISPr-Bandwidth-Max-Down | 10485760
pkg_10mbps_basic | WISPr-Bandwidth-Max-Up | 5242880
pkg_10mbps_basic | Session-Timeout | 86400
The QoS policy names (PKG_10M_DOWN, PKG_10M_UP) must exist on the Cisco router. PyroRadius stores the naming convention in the package configuration.
Juniper ERX / MX¶
Juniper uses ERX-Service-Bundle or Juniper-Local-User-Name to reference a pre-configured service profile:
groupname | attribute | value
------------------|-------------------------------------|------------------
pkg_10mbps_basic | ERX-Service-Bundle | PKG-10MBPS-BASIC
pkg_10mbps_basic | WISPr-Bandwidth-Max-Down | 10485760
pkg_10mbps_basic | WISPr-Bandwidth-Max-Up | 5242880
pkg_10mbps_basic | Session-Timeout | 86400
The service bundle name must be configured on the Juniper device in advance. PyroRadius stores the bundle name in the package settings.
Other / Generic¶
For NAS devices that don't have vendor-specific rate-limiting VSAs, PyroRadius uses standard WISPr attributes (recognised by most vendor implementations):
groupname | attribute | value
------------------|-------------------------------------|------------------
pkg_10mbps_basic | WISPr-Bandwidth-Max-Down | 10485760
pkg_10mbps_basic | WISPr-Bandwidth-Max-Up | 5242880
pkg_10mbps_basic | Session-Timeout | 86400
Note: WISPr bandwidth attributes are advisory — not all NAS implementations enforce them as hard limits.
RadiusService Methods¶
App\Services\RadiusService is the single gateway through which PyroRadius writes to the RADIUS database. All code that needs to modify RADIUS tables calls this service — no direct DB writes to radcheck, radusergroup, or radgroupreply outside this class.
syncCustomer(Customer $customer): void¶
Provisions or updates a customer's RADIUS entries. Called after payment renewal, manual activation, or status change to active.
public function syncCustomer(Customer $customer): void
{
$username = $customer->username;
$password = $customer->radius_password; // stored encrypted, decrypted here
$groupName = $this->groupFor($customer->package);
// 1. radcheck — upsert Cleartext-Password
DB::connection('radius')->table('radcheck')->upsert(
['username' => $username, 'attribute' => 'Cleartext-Password', 'op' => ':=', 'value' => $password],
['username', 'attribute'],
['value']
);
// 2. radusergroup — upsert group assignment
DB::connection('radius')->table('radusergroup')->upsert(
['username' => $username, 'groupname' => $groupName, 'priority' => 1],
['username'],
['groupname', 'priority']
);
// 3. Ensure radgroupreply entries exist for this group
$this->syncPackage($customer->package);
}
deleteCustomerRadius(Customer $customer): void¶
Removes a customer's authentication and group entries from RADIUS. Called when a customer expires, is suspended, or is deleted. Does not touch radacct — accounting history is preserved.
public function deleteCustomerRadius(Customer $customer): void
{
$username = $customer->username;
DB::connection('radius')->transaction(function () use ($username) {
DB::connection('radius')->table('radcheck')
->where('username', $username)->delete();
DB::connection('radius')->table('radusergroup')
->where('username', $username)->delete();
});
}
After this call, FreeRADIUS will reject any authentication attempt from this username. Active sessions are not immediately terminated — use KickExpiredOnline command or the manual disconnect button for that.
syncPackage(Package $package): void¶
Writes or updates the radgroupreply entries for a package's group. Called when a package is created or its speed settings change.
public function syncPackage(Package $package): void
{
$groupName = $this->groupFor($package);
$attributes = $this->rateAttributes($package);
// Delete old attributes for this group
DB::connection('radius')->table('radgroupreply')
->where('groupname', $groupName)->delete();
// Insert fresh attributes
DB::connection('radius')->table('radgroupreply')
->insert(array_map(fn($attr) => [
'groupname' => $groupName,
'attribute' => $attr['attribute'],
'op' => '=',
'value' => $attr['value'],
], $attributes));
}
Because syncPackage deletes and re-inserts rather than updating, it is always in sync with the package definition — no stale attributes can linger.
groupFor(Package $package): string¶
Returns the RADIUS group name for a package. The group name is derived deterministically from the package slug:
public function groupFor(Package $package): string
{
return 'pkg_' . Str::slug($package->name, '_');
// "10 Mbps Basic" → "pkg_10_mbps_basic"
}
If a package has a manually configured radius_group_name attribute, that overrides the auto-generated name. This is used for Juniper deployments where the group name must match a pre-configured service bundle.
rateAttributes(Package $package): array¶
Returns the array of ['attribute' => ..., 'value' => ...] pairs for a package, based on the vendor type of all associated NAS devices:
public function rateAttributes(Package $package): array
{
$vendor = $package->vendor ?? 'other'; // from the NAS's vendor setting
$downKbps = $package->download_speed * 1024;
$upKbps = $package->upload_speed * 1024;
return match ($vendor) {
'mikrotik' => [
['attribute' => 'Mikrotik-Rate-Limit', 'value' => "{$package->download_speed}M/{$package->upload_speed}M"],
['attribute' => 'Session-Timeout', 'value' => '86400'],
],
'cisco' => [
['attribute' => 'Cisco-AVPair', 'value' => "sub-qos-policy-in={$package->cisco_policy_down}"],
['attribute' => 'Cisco-AVPair', 'value' => "sub-qos-policy-out={$package->cisco_policy_up}"],
['attribute' => 'WISPr-Bandwidth-Max-Down', 'value' => (string)($downKbps * 1000)],
['attribute' => 'WISPr-Bandwidth-Max-Up', 'value' => (string)($upKbps * 1000)],
['attribute' => 'Session-Timeout', 'value' => '86400'],
],
'juniper' => [
['attribute' => 'ERX-Service-Bundle', 'value' => $package->juniper_bundle_name],
['attribute' => 'WISPr-Bandwidth-Max-Down', 'value' => (string)($downKbps * 1000)],
['attribute' => 'WISPr-Bandwidth-Max-Up', 'value' => (string)($upKbps * 1000)],
['attribute' => 'Session-Timeout', 'value' => '86400'],
],
default => [
['attribute' => 'WISPr-Bandwidth-Max-Down', 'value' => (string)($downKbps * 1000)],
['attribute' => 'WISPr-Bandwidth-Max-Up', 'value' => (string)($upKbps * 1000)],
['attribute' => 'Session-Timeout', 'value' => '86400'],
],
};
}
SyncAllRadius Command¶
Performs a full reconciliation of all RADIUS tables against the application's current state. Run daily at 03:00 and also available to trigger manually from Settings.
Algorithm:
-
Forward sync — active customers: For every customer with
status = 'active', callssyncCustomer(). This catches any customer whose RADIUS entries are missing or stale (e.g., database diverged from a failed partial sync). -
Reverse sync — stale entries: Queries
radcheckfor all usernames that do NOT have a corresponding active customer in the application database. Deletes these ghost entries. -
Package sync: For every package, calls
syncPackage()to ensureradgroupreplyis current. -
Reports: Logs a summary to the audit log and to the system notifications.
// app/Console/Commands/SyncAllRadius.php
public function handle(RadiusService $radius): int
{
$active = Customer::active()->with('package')->cursor();
$synced = 0;
$failed = 0;
foreach ($active as $customer) {
try {
$radius->syncCustomer($customer);
$synced++;
} catch (\Throwable $e) {
Log::error("RADIUS sync failed for {$customer->username}: {$e->getMessage()}");
$failed++;
}
}
// Remove stale RADIUS entries
$appUsernames = Customer::active()->pluck('username')->toArray();
$stale = DB::connection('radius')
->table('radcheck')
->whereNotIn('username', $appUsernames)
->pluck('username');
foreach ($stale as $username) {
DB::connection('radius')->table('radcheck')->where('username', $username)->delete();
DB::connection('radius')->table('radusergroup')->where('username', $username)->delete();
}
$this->info("Synced: $synced | Failed: $failed | Stale removed: {$stale->count()}");
return self::SUCCESS;
}
Package Speed Change Propagation¶
When a package's download or upload speed is changed in the admin UI:
PUT /packages/{package}
↓
PackageController::update()
↓
$package->update(['download_speed' => ..., 'upload_speed' => ...])
↓
PackageObserver::updated() [fires because download_speed or upload_speed isDirty]
↓
RadiusService::syncPackage($package)
├── DELETE FROM radgroupreply WHERE groupname = 'pkg_10mbps_basic'
└── INSERT INTO radgroupreply (new speed attributes)
The change takes effect for new connections immediately (FreeRADIUS reads radgroupreply fresh each authentication). Existing sessions (customers already connected) will use the new speed on their next re-authentication (PPPoE re-dial or session timeout).
To apply immediately to existing sessions, an admin can use the NAS Sessions page to disconnect individual sessions or bulk-disconnect all sessions on the package — this forces the customer's CPE to re-authenticate and receive the new attributes.
Customer Lifecycle in RADIUS¶
Customer Created (status='pending')
└── No RADIUS entries yet
│
│ Payment received → BillingService::renew()
▼
Customer Active (status='active')
└── RADIUS entries created:
radcheck: username + password
radusergroup: username → group
radgroupreply: group → speed attrs (if not already there)
│
│ expiry_date passes → CheckExpiry command
▼
Customer Expired (status='expired')
└── RadiusService::deleteCustomerRadius()
radcheck: row deleted
radusergroup: row deleted
(radgroupreply: NOT deleted — shared by all customers on this package)
│
├── Admin sets temp_extended: RADIUS entries NOT deleted; expiry check skipped
│
│ Payment received → BillingService::renew()
▼
Customer Active again (status='active')
└── RadiusService::syncCustomer() re-creates radcheck + radusergroup
│
│ Admin suspends manually
▼
Customer Suspended (status='suspended')
└── RadiusService::deleteCustomerRadius() (same as expired)
│
│ Admin re-activates (POST /customers/{id}/status, status='active')
▼
Customer Active again
└── RadiusService::syncCustomer() re-creates entries
│
│ Admin marks as disconnected (line removed, equipment returned)
▼
Customer Disconnected (status='disconnected')
└── RADIUS entries deleted; not expected to reconnect
│
│ Customer soft-deleted
▼
Customer Deleted (soft delete)
└── RADIUS entries already deleted at disconnection step
CloseGhostSessions Command¶
FreeRADIUS writes an Accounting-Start record when a session begins and updates acctstoptime when it ends. If a NAS device reboots without sending an Accounting-Stop, sessions remain open in radacct forever with acctstoptime IS NULL.
This command closes these ghost sessions:
UPDATE radacct
SET acctstoptime = NOW(),
acctterminatecause = 'Lost-Carrier'
WHERE acctstoptime IS NULL
AND acctstarttime < NOW() - INTERVAL '24 hours';
Run every 6 hours. The 24-hour threshold avoids closing legitimate long-running sessions (leased-line customers or static IP customers who stay connected for days).
KickExpiredOnline Command¶
Identifies customers who are currently online in radacct (active session) but whose expiry_date has passed in the application database. For each such customer:
- Looks up their active sessions in
radacct. - For each session, sends a Disconnect-Request (CoA) or an API-level disconnect to the NAS.
- For MikroTik NAS devices: uses the RouterOS API to remove the active PPPoE session (
/ppp/active/remove .id=<id>). - For other vendors: sends a RADIUS Change-of-Authorization (CoA) packet with
Disconnect-Request. - After disconnection, FreeRADIUS will reject the re-authentication attempt (because
deleteCustomerRadiushas already removed theradcheckentry).
// app/Console/Commands/KickExpiredOnline.php
public function handle(RadiusService $radius, MikroTikService $mikrotik): int
{
$expiredUsernames = Customer::expired()
->pluck('username')
->toArray();
$onlineSessions = DB::connection('radius')
->table('radacct')
->whereNull('acctstoptime')
->whereIn('username', $expiredUsernames)
->get();
foreach ($onlineSessions as $session) {
try {
$nas = Nas::where('ip', $session->nasipaddress)->first();
if ($nas && $nas->vendor === 'mikrotik') {
$mikrotik->disconnectSession($nas, $session->acctsessionid);
} else {
// Send CoA Disconnect-Request via RADIUS (RFC 5176)
$radius->sendDisconnectRequest($session);
}
} catch (\Throwable $e) {
Log::warning("Could not kick session for {$session->username}: {$e->getMessage()}");
}
}
$this->info("Kicked {$onlineSessions->count()} expired online sessions.");
return self::SUCCESS;
}
MikroTik Session Disconnect (RouterOS API)¶
For MikroTik NAS devices, live session management uses the RouterOS API (TCP port 8728, plaintext; or 8729, TLS). The MikroTikService class handles this.
// Disconnect an active PPPoE session by username
public function disconnectSession(Nas $nas, string $sessionId): void
{
$api = $this->connect($nas->ip, $nas->api_port, $nas->api_user, $nas->api_password);
// List active sessions matching the session ID
$sessions = $api->query('/ppp/active/print')
->where('session-id', $sessionId)
->read();
foreach ($sessions as $session) {
$api->command('/ppp/active/remove', ['.id' => $session['.id']]);
}
}
After disconnection, the CPE attempts to re-dial. FreeRADIUS checks radcheck — if the entry is gone (customer expired), the re-authentication is rejected and the customer is offline.
RADIUS Connection Configuration¶
Laravel's config/database.php defines a second connection for the RADIUS database:
'radius' => [
'driver' => 'pgsql',
'host' => env('RADIUS_DB_HOST', '127.0.0.1'),
'port' => env('RADIUS_DB_PORT', '5432'),
'database' => env('RADIUS_DB_DATABASE', 'freeradius'),
'username' => env('RADIUS_DB_USERNAME', 'radius'),
'password' => env('RADIUS_DB_PASSWORD', ''),
'schema' => 'public',
'options' => [
PDO::ATTR_TIMEOUT => 5, // fail fast if RADIUS DB is unreachable
],
],
All RadiusService methods use DB::connection('radius') explicitly — never the default DB:: facade — to ensure RADIUS writes always go to the correct database.
Error Handling in RADIUS Operations¶
RadiusService wraps all database operations in try-catch. If a RADIUS sync fails (e.g., RADIUS database temporarily unreachable):
- The exception is caught and re-thrown as a
RadiusSyncException. - The
RadiusSyncFailedevent is fired. - The
NotifyAdminOnRadiusFailurelistener sends an in-app notification to super admins. - The customer is still marked
activein the application database — the RADIUS sync is retried by the nextSyncAllRadiusrun (daily at 03:00). - Failed syncs are logged to
storage/logs/radius.log.
This means a RADIUS database outage does not cause billing operations to fail — payments can still be recorded; RADIUS access will be restored on the next sync.