# ISP360 FreeRADIUS Integration Specification

**Version:** 1.0  
**Date:** 2026-06-13  
**Prepared by:** Polakis Technologies  

Companion: [DATABASE_DESIGN.md](DATABASE_DESIGN.md) | [MIKROTIK_INTEGRATION.md](MIKROTIK_INTEGRATION.md) | [RBAC_SPEC.md](RBAC_SPEC.md)

---

## 1. Overview

ISP360 uses **FreeRADIUS 3.x** as the central authentication and accounting server for:

- **PPPoE** — MikroTik PPPoE server → RADIUS auth + accounting
- **Hotspot** — MikroTik hotspot → RADIUS auth + accounting
- **Voucher** — Voucher code as RADIUS username
- **MAC authentication** — Calling-Station-Id (MAC) as username

ISP360 is the **source of truth** for user provisioning. FreeRADIUS reads from standard `rad*` tables in the shared `isp360` MySQL database. ISP360 syncs users via `RadiusManager.php`.

### Design Principles

| Principle | Implementation |
|-----------|----------------|
| ISP360 owns users | `pppoe_accounts`, `hotspot_users`, `vouchers` → synced to `radcheck` |
| Standard FreeRADIUS schema | `radcheck`, `radreply`, `radgroupcheck`, `radgroupreply`, `radacct`, `radpostauth`, `nas` |
| Multi-tenant isolation | `tenant_id` on all RADIUS tables + username prefix + NAS lookup |
| Shadow profiles | `radius_profiles` → `radgroupreply` / `radgroupcheck` |
| Async accounting rollup | `radacct` → `pppoe_sessions` / `hotspot_sessions` / `hotspot_usage` via cron |

---

## 2. Architecture

```mermaid
flowchart TB
    subgraph clients [NAS Clients - MikroTik Routers]
        PPPoE["PPPoE Server"]
        Hotspot["Hotspot Server"]
    end

    subgraph radius [FreeRADIUS 3.x]
        Auth["radiusd - auth port 1812"]
        Acct["radiusd - acct port 1813"]
        CoA["radiusd - coa port 3799"]
        SQL["rlm_sql module"]
    end

    subgraph isp360 [ISP360 Platform]
        RM["RadiusManager.php"]
        API["api/v1/radius/"]
        CronSync["cron/radius_sync.php"]
        CronAcct["cron/radius_accounting_rollup.php"]
    end

    subgraph mysql [MySQL isp360]
        RadCheck["radcheck / radreply"]
        RadGroup["radgroupcheck / radgroupreply"]
        RadAcct["radacct"]
        NAS["nas"]
        Shadow["radius_profiles / pppoe_accounts / hotspot_users"]
        Sessions["pppoe_sessions / hotspot_sessions"]
    end

    PPPoE -->|Access-Request| Auth
    Hotspot -->|Access-Request| Auth
    Auth --> SQL
    SQL --> RadCheck
    SQL --> RadGroup
    SQL --> NAS
    Auth -->|Access-Accept/Reject| PPPoE
    Auth -->|Access-Accept/Reject| Hotspot

    PPPoE -->|Accounting-Request| Acct
    Hotspot -->|Accounting-Request| Acct
    Acct --> SQL
    SQL --> RadAcct

    RM --> Shadow
    RM --> RadCheck
    CronSync --> RM
    CronAcct --> RadAcct
    CronAcct --> Sessions
    CoA --> PPPoE
    CoA --> Hotspot
```

---

## 3. Multi-Tenant Isolation

### 3.1 Username Format

All RADIUS usernames are **tenant-prefixed**:

```
{tenant_code}.{local_username}
```

| Service | Local Username | RADIUS Username Example |
|---------|----------------|-------------------------|
| PPPoE | `john.doe` | `acmeisp.john.doe` |
| Hotspot | `guest001` | `acmeisp.guest001` |
| Voucher | `VCH-8X4K2M` | `acmeisp.VCH-8X4K2M` |
| MAC auth | `AA:BB:CC:DD:EE:FF` | `acmeisp.AA:BB:CC:DD:EE:FF` |

FreeRADIUS `sql` queries filter by full prefixed username. `tenant_id` is also stored on every `rad*` row for ISP360 application queries.

### 3.2 Tenant Resolution at Auth Time

```mermaid
flowchart LR
    A[Access-Request] --> B[Extract NAS-IP-Address]
    B --> C[Lookup nas table]
    C --> D[Resolve tenant_id]
    D --> E[Validate username prefix matches tenant_code]
    E --> F[SQL authorize radcheck]
```

1. `NAS-IP-Address` → `nas.nasname` → `nas.tenant_id`
2. Username prefix must match `tenants.tenant_code` for that `tenant_id`
3. Mismatch → `Access-Reject`

### 3.3 NAS Secret per Router

Each MikroTik router has a unique RADIUS secret stored in `nas.secret` (encrypted at rest in ISP360, plain in FreeRADIUS `clients.conf` or SQL `nas` table).

---

## 4. Schema Mapping

### 4.1 ISP360 Source → RADIUS Target

| ISP360 Source Table | RADIUS Target | Sync Direction |
|---------------------|---------------|----------------|
| `pppoe_accounts` | `radcheck` + `radreply` + `radius_group_members` | ISP360 → RADIUS |
| `hotspot_users` | `radcheck` + `radreply` | ISP360 → RADIUS |
| `vouchers` (on activate) | `radcheck` (temporary) | ISP360 → RADIUS |
| `hotspot_mac_auth` / `mac_auth_devices` | `radcheck` (Auth-Type Accept) | ISP360 → RADIUS |
| `radius_profiles` | `radgroupcheck` + `radgroupreply` | ISP360 → RADIUS |
| `radius_profile_attributes` | group check/reply rows | ISP360 → RADIUS |
| `packages` + `package_limits` | `radgroupreply` (rate limits) | ISP360 → RADIUS |
| `routers` | `nas` | ISP360 → RADIUS |
| `radacct` | `pppoe_sessions`, `hotspot_sessions`, `hotspot_usage` | RADIUS → ISP360 (rollup) |
| `radpostauth` | `login_history` (optional) | RADIUS → ISP360 (async) |

### 4.2 `radcheck` Attribute Mapping

#### PPPoE Account (active)

| attribute | op | value | Source |
|-----------|-----|-------|--------|
| `Cleartext-Password` | `:=` | `{password}` | `pppoe_accounts.password_hash` (decrypted for sync) |
| `NAS-IP-Address` | `==` | `{optional}` | Per-router restriction |

#### PPPoE Account (suspended)

| attribute | op | value |
|-----------|-----|-------|
| `Auth-Type` | `:=` | `Reject` |

#### Hotspot User

| attribute | op | value |
|-----------|-----|-------|
| `Cleartext-Password` | `:=` | `{password}` |
| `Expiration` | `:=` | `{expires_at}` (if set) |

#### Voucher (temporary user)

| attribute | op | value |
|-----------|-----|-------|
| `Cleartext-Password` | `:=` | `{voucher_pin}` |
| `Max-Daily-Session` | `:=` | `{time_limit}` |
| `Expiration` | `:=` | `{voucher_expires}` |

#### MAC Authentication

| attribute | op | value |
|-----------|-----|-------|
| `Auth-Type` | `:=` | `Accept` |
| `User-Name` | `:=` | `{tenant_code}.{mac}` |

### 4.3 `radreply` Attribute Mapping

| attribute | value | Source |
|-----------|-------|--------|
| `Framed-Pool` | pool name | `pppoe_ip_pools.pool_name` |
| `Framed-IP-Address` | static IP | `pppoe_static_ips.ip_address` |
| `Mikrotik-Rate-Limit` | `10M/10M` | `package_limits` download/upload |
| `Session-Timeout` | seconds | `package_limits.time_limit_minutes * 60` |
| `Acct-Interim-Interval` | `300` | Fixed 5 min interim accounting |

### 4.4 `radgroupreply` (via `radius_profiles.groupname`)

| attribute | value | Example |
|-----------|-------|---------|
| `Mikrotik-Rate-Limit` | `{dl}k/{ul}k` | `5120k/1024k` |
| `Mikrotik-Total-Limit` | bytes | Data cap from `package_limits.data_cap_mb` |
| `Simultaneous-Use` | `1` | `package_limits.concurrent_sessions` |
| `Idle-Timeout` | seconds | `package_limits.idle_timeout_min * 60` |

### 4.5 `radacct` → Session Tables

| radacct field | pppoe_sessions | hotspot_sessions |
|---------------|----------------|------------------|
| `acctsessionid` | `acct_session_id` | `acct_session_id` |
| `username` | `username` (strip prefix) | `username` |
| `nasipaddress` | `nas_ip_address` | `nas_ip_address` |
| `callingstationid` | `caller_id` | `mac_address` |
| `framedipaddress` | `ip_address` | `ip_address` |
| `acctstarttime` | `started_at` | `started_at` |
| `acctstoptime` | `stopped_at` | `stopped_at` |
| `acctsessiontime` | `session_time` | `session_time` |
| `acctinputoctets` | `bytes_in` | `bytes_in` |
| `acctoutputoctets` | `bytes_out` | `bytes_out` |
| `acctterminatecause` | `terminate_cause` | `terminate_cause` |

---

## 5. Class: `RadiusManager`

**File:** `includes/RadiusManager.php`

### 5.1 Constructor

```php
class RadiusManager
{
    private PDO $pdo;
    private int $tenant_id;

    public function __construct(PDO $pdo, int $tenant_id);
}
```

### 5.2 NAS Management

```php
/** Register or update NAS entry when router is added. */
public function registerNas(int $router_id): int;

/** Disable NAS when router deleted. */
public function disableNas(int $router_id): bool;

/** Generate RADIUS secret for router. */
public function generateNasSecret(): string;
```

**NAS row on router add:**
```
nasname     = routers.ip_address
shortname   = routers.router_code
secret      = generated 32-char secret
type        = 'mikrotik'
router_id   = routers.router_id
tenant_id   = routers.tenant_id
coa_port    = 3799
```

### 5.3 User Sync

```php
/** Sync single PPPoE account to radcheck/radreply/group. */
public function syncPppoeAccount(int $account_id): bool;

/** Sync single hotspot user. */
public function syncHotspotUser(int $user_id): bool;

/** Create temporary radcheck for voucher redemption. */
public function syncVoucherAuth(string $voucher_code, int $package_id): bool;

/** Sync MAC auth entry. */
public function syncMacAuth(string $mac_address, int $customer_id = null): bool;

/** Suspend user — set Auth-Type Reject in radcheck. */
public function suspendUser(string $username): bool;

/** Remove user from radcheck/radreply/group members. */
public function removeUser(string $username): bool;

/** Full tenant sync — all PPPoE + hotspot users. */
public function syncAll(int $triggered_by = null): array;

/** Sync radius_profiles to radgroupcheck/radgroupreply. */
public function syncProfiles(): int;
```

### 5.4 Accounting Rollup

```php
/** Process new/updated radacct rows into session tables. */
public function rollupAccounting(?int $since_minutes = 10): int;

/** Aggregate daily usage into hotspot_usage. */
public function aggregateHotspotUsage(string $date = null): int;

/** Aggregate into radius_accounting_summary. */
public function aggregateAccountingSummary(string $date = null): int;
```

### 5.5 COA (Change of Authorization)

```php
/** Disconnect active session via CoA. */
public function coaDisconnect(string $username, string $nas_ip, string $acct_session_id): bool;

/** Change rate limit on active session. */
public function coaChangeRate(string $username, string $nas_ip, string $rate_limit): bool;
```

### 5.6 Helper: Username Prefix

```php
public function formatRadiusUsername(string $local_username): string
{
    // Returns "{tenant_code}.{local_username}"
}

public function parseRadiusUsername(string $radius_username): array
{
    // Returns ['tenant_code' => '...', 'local_username' => '...']
}
```

---

## 6. Authentication Flows

### 6.1 PPPoE Authentication

```mermaid
sequenceDiagram
    participant C as Customer CPE
    participant MT as MikroTik PPPoE
    participant FR as FreeRADIUS
    participant SQL as MySQL radcheck
    participant ISP as ISP360

    C->>MT: PPPoE PAP/CHAP username+password
    MT->>FR: Access-Request User-Name={tenant}.{user} NAS-IP={router}
    FR->>SQL: SELECT nas WHERE nasname=NAS-IP
    FR->>SQL: SELECT radcheck WHERE username AND tenant_id
    alt Password match + not suspended
        FR->>SQL: SELECT radgroupreply via group
        FR-->>MT: Access-Accept + Framed-Pool + Rate-Limit
        MT->>C: PPPoE session established
        MT->>FR: Accounting-Start
    else Invalid or suspended
        FR-->>MT: Access-Reject
        FR->>SQL: INSERT radpostauth reply=Reject
    end
    Note over ISP: RadiusManager synced user at account create
```

**MikroTik PPPoE RADIUS config:**
```
/interface pppoe-server
set [find] authentication=radius use-radius=yes accounting=yes interim-update=5m

/radius
add service=ppp address={radius_server_ip} secret={nas.secret} authentication-port=1812 accounting-port=1813
```

### 6.2 Hotspot Authentication

```mermaid
sequenceDiagram
    participant U as User Browser
    participant MT as MikroTik Hotspot
    participant FR as FreeRADIUS
    participant SQL as MySQL
    participant Portal as Captive Portal

    U->>MT: HTTP request (not authenticated)
    MT->>U: Redirect to captive portal
    U->>Portal: Submit username + password
    Portal->>MT: Login via hotspot (RADIUS)
    MT->>FR: Access-Request User-Name={tenant}.{user}
    FR->>SQL: radcheck lookup
    alt Valid
        FR-->>MT: Access-Accept Session-Timeout Rate-Limit
        MT->>U: Internet access granted
        MT->>FR: Accounting-Start
    else Invalid
        FR-->>MT: Access-Reject
    end
```

**MikroTik Hotspot RADIUS config:**
```
/ip hotspot profile
set [find] use-radius=yes radius-accounting=yes radius-interim-update=5m

/radius
add service=hotspot address={radius_server_ip} secret={nas.secret}
```

### 6.3 Voucher Authentication

```mermaid
sequenceDiagram
    participant U as User
    participant Portal as Captive Portal
    participant AJAX as ajax/captive_voucher_login.php
    participant RM as RadiusManager
    participant SQL as MySQL
    participant MT as MikroTik Hotspot
    participant FR as FreeRADIUS

    U->>Portal: Enter voucher code
    Portal->>AJAX: POST voucher_code
    AJAX->>RM: syncVoucherAuth(code, package_id)
    RM->>SQL: INSERT radcheck Cleartext-Password + Expiration
    RM->>SQL: UPDATE vouchers status=activated
    AJAX-->>Portal: success
    Portal->>MT: Login username={tenant}.{code} password={pin}
    MT->>FR: Access-Request
    FR-->>MT: Access-Accept with Session-Timeout
    MT->>FR: Accounting-Start
    Note over RM: On session stop → voucher status=used
```

**Voucher username:** `{tenant_code}.{voucher_code}`  
**Password:** voucher PIN (or same as code for PIN-less vouchers)

### 6.4 MAC Authentication

```mermaid
sequenceDiagram
    participant D as Device
    participant MT as MikroTik Hotspot
    participant FR as FreeRADIUS
    participant SQL as MySQL

    D->>MT: DHCP / HTTP (unknown MAC)
    MT->>FR: Access-Request User-Name={tenant}.{MAC} Calling-Station-Id={MAC}
    FR->>SQL: radcheck Auth-Type:=Accept for MAC user
    alt MAC registered
        FR-->>MT: Access-Accept
        MT->>FR: Accounting-Start
    else Unknown MAC
        FR-->>MT: Access-Reject
        MT->>D: Redirect to captive portal
    end
```

**MikroTik MAC auth:**
```
/ip hotspot user profile
set [find] mac-auth-mode=mac-as-username

/ip hotspot
set [find] login-by=mac,cookie,http-chap
```

---

## 7. Accounting Flow

### 7.1 Accounting Packet Types

| Status-Type | Action |
|-------------|--------|
| `Start` | INSERT `radacct`; create `pppoe_sessions` / `hotspot_sessions` |
| `Interim-Update` | UPDATE `radacct` octets + session time; update active session |
| `Stop` | UPDATE `radacct` stop time; close session; rollup usage |
| `Accounting-On` | NAS reboot marker — close stale sessions |
| `Accounting-Off` | NAS shutdown — close all sessions for NAS |

### 7.2 Accounting Sequence

```mermaid
sequenceDiagram
    participant MT as MikroTik
    participant FR as FreeRADIUS
    participant SQL as radacct
    participant CRON as radius_accounting_rollup
    participant ISP as pppoe_sessions / hotspot_usage

    MT->>FR: Accounting-Start Acct-Session-Id
    FR->>SQL: INSERT radacct acctstarttime=NOW()

    loop Every 5 minutes
        MT->>FR: Interim-Update octets+time
        FR->>SQL: UPDATE radacct acctupdatetime
    end

    MT->>FR: Accounting-Stop terminate-cause
    FR->>SQL: UPDATE radacct acctstoptime, final octets

    CRON->>SQL: SELECT radacct WHERE updated since last run
    CRON->>ISP: UPSERT pppoe_sessions / hotspot_sessions
    CRON->>ISP: UPSERT hotspot_usage daily aggregate
    CRON->>ISP: UPDATE radius_accounting_summary
```

### 7.3 Rollup Cron

**File:** `cron/radius_accounting_rollup.php`  
**Schedule:** `*/5 * * * *`

```php
$rm = new RadiusManager($pdo, $tenant_id);
$rm->rollupAccounting(10);      // last 10 minutes of radacct changes
$rm->aggregateHotspotUsage();   // daily hotspot_usage
$rm->aggregateAccountingSummary();
```

### 7.4 Session Close Rules

| Condition | ISP360 Action |
|-----------|---------------|
| `acctstoptime` set | Set session `status=stopped` |
| Interim update + quota exceeded | COA disconnect + `status=terminated` |
| NAS Accounting-Off | Close all active sessions for that `nasipaddress` |
| Stale session (no interim > 30 min) | Cron marks `status=terminated` |

### 7.5 Usage Quota Enforcement (FUP)

1. Cron reads `hotspot_usage` / `usage_aggregates_daily` vs `package_limits.data_cap_mb`
2. If exceeded → `RadiusManager::coaDisconnect()`
3. Optionally throttle via `coaChangeRate()` per `package_fup_rules`

---

## 8. FreeRADIUS Configuration

### 8.1 SQL Module (`/etc/freeradius/3.0/mods-available/sql`)

```conf
sql {
    driver = "rlm_sql_mysql"
    dialect = "mysql"

    server = "localhost"
    port = 3306
    login = "radius"
    password = "{RADIUS_DB_PASSWORD}"
    radius_db = "isp360"

    acct_table1 = "radacct"
    acct_table2 = "radacct"
    postauth_table = "radpostauth"
    authcheck_table = "radcheck"
    authreply_table = "radreply"
    groupcheck_table = "radgroupcheck"
    groupreply_table = "radgroupreply"
    usergroup_table = "radius_group_members"

    read_groups = yes
    read_profiles = no

    pool {
        start = 5
        min = 3
        max = 32
        spare = 10
        uses = 0
        lifetime = 0
        idle_timeout = 60
    }
}
```

Enable: `ln -s ../mods-available/sql /etc/freeradius/3.0/mods-enabled/sql`

### 8.2 Custom SQL Queries (tenant-aware)

Override default queries in `/etc/freeradius/3.0/mods-config/sql/main/mysql/queries.conf`:

```conf
# Authorize — check password
authorize_check_query = "\
    SELECT id, username, attribute, value, op \
    FROM radcheck \
    WHERE username = '%{SQL-User-Name}' \
    AND deleted_at IS NULL \
    ORDER BY id"

# Authorize — reply attributes
authorize_reply_query = "\
    SELECT id, username, attribute, value, op \
    FROM radreply \
    WHERE username = '%{SQL-User-Name}' \
    AND deleted_at IS NULL \
    ORDER BY id"

# Group check
authorize_groupcheck_query = "\
    SELECT id, groupname, attribute, value, op \
    FROM radgroupcheck \
    WHERE groupname = '%{SQL-Group}' \
    AND deleted_at IS NULL \
    ORDER BY id"

# Group reply
authorize_groupreply_query = "\
    SELECT id, groupname, attribute, value, op \
    FROM radgroupreply \
    WHERE groupname = '%{SQL-Group}' \
    AND deleted_at IS NULL \
    ORDER BY id"

# Group membership
authorize_group_query = "\
    SELECT groupname, priority \
    FROM radius_group_members \
    WHERE username = '%{SQL-User-Name}' \
    AND deleted_at IS NULL \
    ORDER BY priority"

# NAS lookup (clients via SQL)
client_query = "\
    SELECT id, nasname, shortname, secret, server \
    FROM nas \
    WHERE nasname = '%{Packet-Src-IP-Address}' \
    AND is_active = 1 \
    AND deleted_at IS NULL"
```

### 8.3 Site Default (`/etc/freeradius/3.0/sites-enabled/default`)

Key authorize section:

```conf
authorize {
    filter_username
    preprocess
    sql                    # NAS client lookup + radcheck
    if ((ok || updated) && User-Password && !control:Auth-Type) {
        update control {
            Auth-Type := PAP
        }
    }
    pap
    sql                    # group membership + reply attrs
    expiration
    logintime
}

authenticate {
    Auth-Type PAP {
        pap
    }
}

accounting {
    sql
}

post-auth {
    sql
    Post-Auth-Type REJECT {
        sql
    }
}
```

### 8.4 SQL NAS Clients (recommended over clients.conf)

Enable read clients from `nas` table:

```conf
# radiusd.conf
read_clients = yes
```

FreeRADIUS reads NAS clients from `nas` table via `client_query` — no static `clients.conf` per router.

### 8.5 CoA Configuration

```conf
# sites-enabled/coa
server coa {
    listen {
        type = coa
        ipaddr = *
        port = 3799
    }
    recv-coa {
        sql
        ok
    }
}
```

---

## 9. MikroTik NAS Configuration Template

Applied via `RouterManager` on router registration:

```
# RADIUS servers
/radius
add address={RADIUS_IP} secret={NAS_SECRET} service=ppp,hotspot,dhcp \
    authentication-port=1812 accounting-port=1813 timeout=3s

# PPPoE
/ppp profile
set default use-encryption=required

/interface pppoe-server server
set [find] authentication=radius use-radius=yes accounting=yes interim-update=5m

# Hotspot
/ip hotspot profile
set [find] use-radius=yes radius-accounting=yes radius-interim-update=5m

# Incoming CoA
/radius incoming
set accept=yes port=3799
```

---

## 10. Sync Jobs

| Cron File | Schedule | Action |
|-----------|----------|--------|
| `cron/radius_sync.php` | `*/5 * * * *` | Incremental sync changed accounts/users |
| `cron/radius_accounting_rollup.php` | `*/5 * * * *` | radacct → session tables |
| `cron/session_sync.php` | `*/2 * * * *` | Reconcile MikroTik active ↔ DB sessions |

### Incremental Sync Logic

```php
// radius_sync.php
$accounts = SELECT pppoe_accounts WHERE updated_at > last_sync OR synced_at IS NULL
foreach ($accounts as $account) {
    $rm->syncPppoeAccount($account['account_id']);
}
// Same for hotspot_users, radius_profiles
INSERT radius_sync_log (sync_type, status, records_processed, ...)
```

### Full Sync Trigger

- Manual: `radius_sync.php` admin UI button on `radius_sync.php` page
- On profile change: immediate `syncProfiles()`
- On router add: `registerNas()` only (users sync separately)

---

## 11. API Endpoints

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `api/v1/radius/authenticate.php` | POST | External auth hook (optional) |
| `api/v1/radius/accounting.php` | POST | External acct hook (optional) |
| `api/v1/radius/coa.php` | POST | Trigger COA disconnect |

Most auth/acct flows go **directly to FreeRADIUS** — API hooks are for external integrations only.

---

## 12. Admin UI Pages

| Page | Permission | Purpose |
|------|------------|---------|
| `radius_profiles.php` | `radius.view` | Manage RADIUS profiles |
| `radius_groups.php` | `radius.view` | Group check/reply attributes |
| `radius_sync.php` | `radius.sync` | Sync status + manual trigger |
| `radius_accounting.php` | `radius.view_accounting` | radacct viewer |
| `nas_devices.php` | `routers.view` | NAS registry |

---

## 13. Security

| Requirement | Implementation |
|-------------|----------------|
| Unique NAS secret per router | `generateNasSecret()` 32 chars |
| NAS secret encrypted in ISP360 | `router_credentials` / `nas` display masked |
| Tenant username prefix | Prevents cross-tenant auth |
| radpostauth logging | All reject attempts logged |
| Brute-force on voucher | Rate limit `ajax/captive_voucher_login.php` |
| SQL injection | FreeRADIUS parameterized queries; ISP360 PDO |
| RADIUS message authenticator | Required on all packets (MT + FR 3.x default) |

---

## 14. Deployment Checklist

- [ ] Install FreeRADIUS 3.x + MySQL module on Ubuntu 24.04
- [ ] Create MySQL user `radius@localhost` with SELECT/INSERT/UPDATE on `isp360`
- [ ] Deploy `mods-available/sql` pointing to `isp360` database
- [ ] Deploy custom `queries.conf` with `deleted_at IS NULL` filters
- [ ] Enable SQL NAS client reading from `nas` table
- [ ] Implement `includes/RadiusManager.php`
- [ ] Wire router add → `registerNas()` in `RouterManager`
- [ ] Deploy `cron/radius_sync.php` + `cron/radius_accounting_rollup.php`
- [ ] Test PPPoE auth with CHR/lab router
- [ ] Test hotspot voucher + MAC auth flows
- [ ] Monitor `radpostauth` for reject spikes

---

## Change Log

| Version | Date | Change |
|---------|------|--------|
| 1.0 | 2026-06-13 | Initial FreeRADIUS spec — schema mapping, 4 auth flows, accounting rollup |
