Skip to content

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.

php artisan radius:sync-all

Algorithm:

  1. Forward sync — active customers: For every customer with status = 'active', calls syncCustomer(). This catches any customer whose RADIUS entries are missing or stale (e.g., database diverged from a failed partial sync).

  2. Reverse sync — stale entries: Queries radcheck for all usernames that do NOT have a corresponding active customer in the application database. Deletes these ghost entries.

  3. Package sync: For every package, calls syncPackage() to ensure radgroupreply is current.

  4. 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

php artisan radius:close-ghost-sessions

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

php artisan radius:kick-expired

Identifies customers who are currently online in radacct (active session) but whose expiry_date has passed in the application database. For each such customer:

  1. Looks up their active sessions in radacct.
  2. For each session, sends a Disconnect-Request (CoA) or an API-level disconnect to the NAS.
  3. For MikroTik NAS devices: uses the RouterOS API to remove the active PPPoE session (/ppp/active/remove .id=<id>).
  4. For other vendors: sends a RADIUS Change-of-Authorization (CoA) packet with Disconnect-Request.
  5. After disconnection, FreeRADIUS will reject the re-authentication attempt (because deleteCustomerRadius has already removed the radcheck entry).
// 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):

  1. The exception is caught and re-thrown as a RadiusSyncException.
  2. The RadiusSyncFailed event is fired.
  3. The NotifyAdminOnRadiusFailure listener sends an in-app notification to super admins.
  4. The customer is still marked active in the application database — the RADIUS sync is retried by the next SyncAllRadius run (daily at 03:00).
  5. 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.