16 KiB
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, spanningradcheck+radusergroup+customers(status), with human metadata (name/phone/alias) in the standaloneradadmin_clientstable. - Devices (
/device) — the NAS/AP boxes seen inradacct, keyed by AP MAC (fromcalledstationid), with a human alias in the standaloneradadmin_devicestable.
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-Keyheader
Setup
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.1only. 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@HOSTthenDB_PORT=13306), or bind MariaDB to the LAN and grant remote access.
Run
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: <your 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: <API_KEY> (except /health).
All bodies: JSON with Content-Type: application/json.
Response shapes
GET /client/ — paginated envelope:
{
"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:
{ "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):
[ { "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):
[ { "alias": "residents", "vlanid": 51 }, { "alias": "staff", "vlanid": 55 } ]
statusis always one of"new","paid","unpaid".group,name,phone,aliasmay benullon older rows.DELETEreturns204with an empty body (nothing to parse).
Error shapes
Application errors (400, 401, 404, 409) return a string detail:
{ "detail": "Client 'AA-BB-CC-DD-EE-11' already exists" }
Validation errors (422, bad/missing fields) return a FastAPI array detail:
{ "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/addwithmac_address,group(an existing VLAN alias),name,phone, optionalalias. Handle400(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/→ mapalias(value sent asgroup). - 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:
{ "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=<vlanid>.
| 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. vlanidmust be1–4094.- Renaming updates
radgroupreply.groupnameonly; if you also map users to groups inradusergroup, 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
radcheckpassword (Cleartext-Password := MAC), aradusergrouprow (priority 1), acustomersrow (status = paid), and aradadmin_clientsrow (name/phone/alias). Thegroupmust already exist inradgroupreplyor you get400. - Edit accepts any subset of
group,status,name,phone,alias(at least one required); omitted fields are left unchanged. A newgroupmust exist inradgroupreply(400otherwise).statusmust be one ofnew/paid/unpaid. In the DB the group is stored in theradusergroup.groupnamecolumn,statusincustomers.status, andname/phone/aliasinradadmin_clients. - Adding a client whose MAC already exists →
409. - Import validates each row with the same rules as
add; withdry_run:truenothing is written and it returns{total, valid, created:0, dry_run, errors:[{row, mac, detail}]}for a preview. Withdry_run:falsethe valid rows are inserted best-effort (invalid rows reported, not fatal). The JSON field isdevices(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
(<AP-MAC>:<SSID>): 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.
nasipaddressis the NAS IP(s) the AP reports from (comma-joined if more than one);ssidsis the full list of SSIDs seen for that AP MAC.PUTupserts theradadmin_devicesrow 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.
# 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, ...