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).
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 two primary resources are 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 /device/ — 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 /device/{mac}, POST /device/add, POST /device/edit — a single device object:
{ "mac_address": "AA-BB-CC-DD-EE-11", "group": "residents", "status": "paid",
"name": "Sara Ib", "phone": "9998887", "alias": "Living Room TV" }
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": "Device '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 device:
POST /device/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 /device/edit{mac_address, status}. - Move to another VLAN:
POST /device/edit{mac_address, group}. - Populate a VLAN dropdown:
GET /vlan/→ mapalias(value sent asgroup).
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 | — |
| Devices | /device |
see below | — |
| 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.
Devices — /device
A device is a client identified by its MAC address (used as the RADIUS username).
One device spans three tables: radcheck (MAC = password), radusergroup (group
membership), and customers (status + human metadata). MAC input is normalized to
uppercase, hyphen-separated (AA-BB-CC-DD-EE-FF); colons and lowercase are accepted.
The customers table also carries human-only metadata that RADIUS never reads:
name, phone, and device_alias. These are collected at device creation.
| Action | Request |
|---|---|
| List devices | GET /device/ → {total,limit,offset,items:[{mac_address,group,status,name,phone,alias}]} |
| Get one device | GET /device/{mac_address} |
| Add a device | POST /device/add body {"mac_address":"14-99-3E-74-CB-7F","group":"staff","name":"Ali Hassan","phone":"7712345","alias":"Living Room TV"} |
| Edit a device | POST /device/edit body {"mac_address":"...", group?, status?, name?, phone?, alias?} |
| Delete a device | DELETE /device/{mac_address} (removes all 3 rows) |
On add, name and phone are required; alias is optional. They are
stored in customers.name, customers.phone, customers.device_alias and returned
on every device response (alias mirrors the device_alias column).
- Add inserts a
radcheckpassword (Cleartext-Password := MAC), aradusergrouprow (priority 1), and acustomersrow (status = paid). 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 andaliasincustomers.device_alias. - Adding a device whose MAC already exists →
409.
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 devices
curl http://10.0.1.235:8000/device/ \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
# Add a device (MAC + existing group + name/phone required, alias optional)
curl http://10.0.1.235:8000/device/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 device (any subset of group/status/name/phone/alias)
curl http://10.0.1.235:8000/device/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
# Delete a device
curl http://10.0.1.235:8000/device/14-99-3E-74-CB-7F \
-X DELETE \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -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 / 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 (one per table)
schemas.py Pydantic request/response models
routers/ one module per resource