# FreeRADIUS REST API A FastAPI service exposing RESTful CRUD over the FreeRADIUS MySQL/MariaDB schema (`customers`, `radcheck`, `radreply`, `radgroupreply`/vlans, `radusergroup`, `nas`, plus read-only `radacct`, `radpostauth`, `nasreload`). Two endpoints are **logical views** layered over that schema rather than raw tables: - **Clients** (`/client`) — customer devices keyed by MAC, spanning `radcheck` + `radusergroup` + `customers` (status), with human metadata (name/phone/alias) in the standalone **`radadmin_clients`** table. - **Devices** (`/device`) — the NAS/AP boxes seen in `radacct`, keyed by **AP MAC** (from `calledstationid`), with a human alias in the standalone **`radadmin_devices`** table. The two `radadmin_*` tables hold only human labels — RADIUS never reads them. ## Stack - **FastAPI** + **Uvicorn** (ASGI) - **SQLAlchemy 2.0** ORM + **PyMySQL** driver - **Pydantic v2** request/response validation - Auth via a shared secret in the **`X-API-Key`** header ## Setup ```bash python3 -m venv venv venv/bin/pip install -r requirements.txt cp .env.example .env # then edit credentials + API_KEY ``` ### `.env` | Var | Meaning | |-----------------|------------------------------------------------| | `DB_HOST` | MySQL host (`127.0.0.1` if API runs on the DB box) | | `DB_PORT` | MySQL port (default 3306) | | `DB_USER` / `DB_PASSWORD` | DB credentials | | `DB_NAME` | Database name (`radius`) | | `API_KEY` | Shared secret required in `X-API-Key` | | `CORS_ORIGINS` | Comma-separated allowed origins (`*` for dev) | | `DEFAULT_LIMIT` / `MAX_LIMIT` | List pagination caps | > **Note:** MariaDB on the staging box binds to `127.0.0.1` only. To reach it from > another host either run the API on the RADIUS server (`DB_HOST=127.0.0.1`), open > an SSH tunnel (`ssh -N -L 13306:127.0.0.1:3306 root@HOST` then `DB_PORT=13306`), > or bind MariaDB to the LAN and grant remote access. ## Run ```bash venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 ``` - Interactive docs (Swagger): `http://HOST:8000/docs` - OpenAPI JSON: `http://HOST:8000/openapi.json` - Health check (no auth): `GET /health` ## Auth Every resource endpoint requires the header: ``` X-API-Key: ``` Missing/wrong key → `401`. `/`, `/health`, and `/docs` are open. ## UI integration guide For a management portal the primary resources are **Clients** (`/client`), **Devices** (`/device`), and **VLANs** (`/vlan`). Everything else is lower-level raw-table access. **Base URL (staging):** `http://10.0.1.235:8000` **Every request:** header `X-API-Key: ` (except `/health`). **All bodies:** JSON with `Content-Type: application/json`. ### Response shapes `GET /client/` — **paginated** envelope: ```json { "total": 4, "limit": 50, "offset": 0, "items": [ { "mac_address": "AA-BB-CC-DD-EE-11", "group": "residents", "status": "paid", "name": "Sara Ib", "phone": "9998887", "alias": "Living Room TV" } ] } ``` `GET /client/{mac}`, `POST /client/add`, `POST /client/edit` — a single client object: ```json { "mac_address": "AA-BB-CC-DD-EE-11", "group": "residents", "status": "paid", "name": "Sara Ib", "phone": "9998887", "alias": "Living Room TV" } ``` `GET /device/` — **plain array** of NAS devices (not paginated): ```json [ { "ap_mac": "3C-52-A1-93-06-FC", "nasipaddress": "192.168.1.102", "ssids": ["VSARLINK", "Guest"], "alias": "Lobby AP" } ] ``` `GET /vlan/` — **plain array** (not paginated): ```json [ { "alias": "residents", "vlanid": 51 }, { "alias": "staff", "vlanid": 55 } ] ``` - `status` is always one of `"new"`, `"paid"`, `"unpaid"`. - `group`, `name`, `phone`, `alias` may be `null` on older rows. - `DELETE` returns **`204` with an empty body** (nothing to parse). ### Error shapes Application errors (`400`, `401`, `404`, `409`) return a **string** detail: ```json { "detail": "Client 'AA-BB-CC-DD-EE-11' already exists" } ``` Validation errors (`422`, bad/missing fields) return a FastAPI **array** detail: ```json { "detail": [ { "type": "missing", "loc": ["body","phone"], "msg": "Field required" } ] } ``` So in the UI: read `detail` directly when it's a string; when it's an array, join each entry's `msg` (and `loc`) for field-level messages. ### Typical portal flows - **Provision a client:** `POST /client/add` with `mac_address`, `group` (an existing VLAN alias), `name`, `phone`, optional `alias`. Handle `400` (group missing), `409` (MAC exists), `422` (bad MAC / missing name·phone). - **Change plan state:** `POST /client/edit` `{mac_address, status}`. - **Move to another VLAN:** `POST /client/edit` `{mac_address, group}`. - **Bulk import clients:** `POST /client/import` `{devices:[...], dry_run}` — see the Clients section. - **Populate a VLAN dropdown:** `GET /vlan/` → map `alias` (value sent as `group`). - **Label a NAS device:** `GET /device/` to list them, `PUT /device/{ip}` `{alias}`. ## Endpoints All list endpoints return a paginated envelope and accept `?limit=&offset=` plus per-resource filters: ```json { "total": 12, "limit": 50, "offset": 0, "items": [ ... ] } ``` | Resource | Path | Methods | Filters | |-----------------|-------------------|--------------------------|----------------------------------| | Customers | `/customers` | GET, POST, PUT, DELETE | `username`, `mac_address`, `status` | | NAS clients | `/nas` | GET, POST, PUT, DELETE | `nasname`, `shortname` | | User check | `/radcheck` | GET, POST, PUT, DELETE | `username`, `attribute` | | User reply | `/radreply` | GET, POST, PUT, DELETE | `username`, `attribute` | | Group check | `/radgroupcheck` | GET, POST, PUT, DELETE | `groupname`, `attribute` | | Group reply | `/radgroupreply` | GET, POST, PUT, DELETE | `groupname`, `attribute` | | **VLANs** | `/vlan` | see below | — | | **Clients** | `/client` | see below | — | | **Devices** | `/device` | GET (list), PUT (alias) | — | | User↔group | `/radusergroup` | GET, POST, PUT, DELETE | `username`, `groupname` | | Accounting | `/radacct` | GET (read-only) | `username`, `nasipaddress`, `active` | | Post-auth log | `/radpostauth` | GET (read-only) | `username`, `reply` | | NAS reload | `/nasreload` | GET (read-only) | — | `radacct`, `radpostauth`, and `nasreload` are **read-only** — FreeRADIUS owns writes. ### VLANs — `/vlan` A VLAN is a logical entity (`alias` + `vlanid`) backed by three `radgroupreply` rows sharing one `groupname`: `Tunnel-Type=VLAN`, `Tunnel-Medium-Type=IEEE-802`, and `Tunnel-Private-Group-Id=`. | Action | Request | |------------------|-----------------------------------------------------| | List VLANs | `GET /vlan/` → `[{"alias","vlanid"}, ...]` | | Add a VLAN | `POST /vlan/add` body `{"vlanid":55,"alias":"staff"}` (inserts the 3 rows) | | Rename alias | `POST /vlan/edit` body `{"vlanid":55,"alias":"employees"}` | | Delete a VLAN | `DELETE /vlan/{vlanid}` (removes all rows for that group) | - List returns **one row per VLAN** — only the alias and VLAN ID, not the raw `Tunnel-*` attribute rows. - Duplicate **VLAN ID** or **alias** on add → `409`. - `vlanid` must be `1–4094`. - Renaming updates `radgroupreply.groupname` only; if you also map users to groups in `radusergroup`, update those separately. ### Clients — `/client` A client is identified by its MAC address (used as the RADIUS `username`). One client spans four tables: `radcheck` (MAC = password), `radusergroup` (group membership), `customers` (status), and `radadmin_clients` (human metadata). MAC input is normalized to uppercase, hyphen-separated (`AA-BB-CC-DD-EE-FF`); colons and lowercase are accepted. The **`radadmin_clients`** table carries **human-only metadata that RADIUS never reads** — `name`, `phone`, and `alias`, keyed by `mac_address`. These are collected at client creation. | Action | Request | |------------------|-----------------------------------------------------| | List clients | `GET /client/` → `{total,limit,offset,items:[{mac_address,group,status,name,phone,alias}]}` | | Get one client | `GET /client/{mac_address}` | | Add a client | `POST /client/add` body `{"mac_address":"14-99-3E-74-CB-7F","group":"staff","name":"Ali Hassan","phone":"7712345","alias":"Living Room TV"}` | | Edit a client | `POST /client/edit` body `{"mac_address":"...", group?, status?, name?, phone?, alias?}` | | Import clients | `POST /client/import` body `{"devices":[{...}], "dry_run":true}` (bulk CSV import) | | Delete a client | `DELETE /client/{mac_address}` (removes all rows) | On **add**, `name` and `phone` are **required**; `alias` is **optional**. They are stored in `radadmin_clients` (`name`, `phone`, `alias`) and returned on every client response. - **Add** inserts a `radcheck` password (`Cleartext-Password := MAC`), a `radusergroup` row (`priority 1`), a `customers` row (`status = paid`), and a `radadmin_clients` row (name/phone/alias). The `group` must already exist in `radgroupreply` or you get `400`. - **Edit** accepts any subset of `group`, `status`, `name`, `phone`, `alias` (at least one required); omitted fields are left unchanged. A new `group` must exist in `radgroupreply` (`400` otherwise). `status` must be one of `new` / `paid` / `unpaid`. In the DB the group is stored in the `radusergroup.groupname` column, `status` in `customers.status`, and `name`/`phone`/`alias` in `radadmin_clients`. - Adding a client whose MAC already exists → `409`. - **Import** validates each row with the same rules as `add`; with `dry_run:true` nothing is written and it returns `{total, valid, created:0, dry_run, errors:[{row, mac, detail}]}` for a preview. With `dry_run:false` the valid rows are inserted best-effort (invalid rows reported, not fatal). The JSON field is `devices` (rows). ### Devices — `/device` A device here is a **NAS/AP box**, keyed by its **AP MAC** — not a customer MAC (those are Clients). The list is derived from `radacct.calledstationid` (`:`): rows are grouped by the AP MAC, so one physical AP is a single entry even when it broadcasts several SSIDs. Each entry aggregates every `ssid` seen and the NAS IP(s) it reported from. An editable human `alias` is stored in the standalone **`radadmin_devices`** table (keyed by AP MAC), which FreeRADIUS never reads. | Action | Request | |------------------|-----------------------------------------------------| | List devices | `GET /device/` → `[{ap_mac, nasipaddress, ssids:[...], alias}]` | | Set/clear alias | `PUT /device/{ap_mac}` body `{"alias":"Lobby AP"}` (`alias:null` clears) | - List is a **plain array** (not paginated), ordered by AP MAC. - `nasipaddress` is the NAS IP(s) the AP reports from (comma-joined if more than one); `ssids` is the full list of SSIDs seen for that AP MAC. - `PUT` upserts the `radadmin_devices` row for that AP MAC — no row need pre-exist; the MAC is normalized to uppercase-hyphen form. ## Examples Replace host/key to match your deployment. ```bash # List customers curl http://10.0.1.235:8000/customers?limit=50 \ -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq # List VLANs curl http://10.0.1.235:8000/vlan/ \ -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq # Add a VLAN (alias "staff", VLAN ID 55) curl http://10.0.1.235:8000/vlan/add \ -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \ -H "Content-Type: application/json" \ -d '{"vlanid":55,"alias":"staff"}' -s | jq # Rename a VLAN's alias curl http://10.0.1.235:8000/vlan/edit \ -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \ -H "Content-Type: application/json" \ -d '{"vlanid":55,"alias":"employees"}' -s | jq # Delete VLAN 55 curl http://10.0.1.235:8000/vlan/55 \ -X DELETE \ -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq # List clients curl http://10.0.1.235:8000/client/ \ -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq # Add a client (MAC + existing group + name/phone required, alias optional) curl http://10.0.1.235:8000/client/add \ -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \ -H "Content-Type: application/json" \ -d '{"mac_address":"14-99-3E-74-CB-7F","group":"staff","name":"Ali Hassan","phone":"7712345","alias":"Living Room TV"}' -s | jq # Edit a client (any subset of group/status/name/phone/alias) curl http://10.0.1.235:8000/client/edit \ -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \ -H "Content-Type: application/json" \ -d '{"mac_address":"14-99-3E-74-CB-7F","status":"unpaid","alias":"Living Room TV"}' -s | jq # Bulk-import clients (dry-run — preview only, nothing written) curl http://10.0.1.235:8000/client/import \ -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \ -H "Content-Type: application/json" \ -d '{"dry_run":true,"devices":[{"mac_address":"14-99-3E-74-CB-7F","group":"staff","name":"Ali","phone":"7712345"}]}' -s | jq # Delete a client curl http://10.0.1.235:8000/client/14-99-3E-74-CB-7F \ -X DELETE \ -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq # List NAS devices (IPs seen in radacct, with alias/AP-MAC/SSID) curl http://10.0.1.235:8000/device/ \ -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq # Set a device alias by AP MAC (PUT; send {"alias":null} to clear) curl http://10.0.1.235:8000/device/3C-52-A1-93-06-FC \ -X PUT \ -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \ -H "Content-Type: application/json" \ -d '{"alias":"Lobby AP"}' -s | jq # Create a customer curl http://10.0.1.235:8000/customers \ -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \ -H "Content-Type: application/json" \ -d '{"username":"AA-BB-CC-DD-EE-FF","mac_address":"AA-BB-CC-DD-EE-FF","status":"new"}' -s | jq # Update a customer's status curl http://10.0.1.235:8000/customers/1 \ -X PUT \ -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \ -H "Content-Type: application/json" \ -d '{"status":"paid"}' -s | jq # Active accounting sessions (no stop time) curl http://10.0.1.235:8000/radacct?active=true \ -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq # Health check (no key needed) curl http://10.0.1.235:8000/health -s | jq ``` ## Status codes | Code | Meaning | |------|---------------------------------------------| | 200 | OK | | 201 | Created | | 204 | Deleted (no content) | | 400 | Bad request (e.g. referenced group/VLAN doesn't exist) | | 401 | Missing/invalid `X-API-Key` | | 404 | Row / client / device / VLAN not found | | 409 | Duplicate / integrity conflict | | 422 | Request body failed validation | ## Project layout ``` app/ main.py FastAPI app, router wiring, auth + CORS config.py env-driven settings (pydantic-settings) database.py SQLAlchemy engine/session auth.py X-API-Key dependency errors.py APIError + JSON handler crud.py generic list/get/create/update/delete helpers pagination.py Page envelope + limit/offset dependency models.py SQLAlchemy models (incl. radadmin_clients, radadmin_devices) schemas.py Pydantic request/response models routers/ one module per resource client.py Clients — MAC view over radcheck+radusergroup+customers device.py Devices — AP MACs from radacct + radadmin_devices alias vlan.py VLANs — view over radgroupreply ... customers, nas, radcheck/reply, radgroup*, radacct, ... ```