21 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
- Per-user auth: username/password login sessions (
Authorization: Bearer) plus admin-issuedX-API-Keykeys for programmatic access
Setup
python3 -m venv venv
venv/bin/pip install -r requirements.txt
cp .env.example .env # then edit DB credentials
Import radadmin_schema.sql (after the FreeRADIUS schema.sql) so the auth
tables exist. On first startup the API seeds a default admin / admin
login and forces a password change on first sign-in.
.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) |
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
Auth is per-user, not a shared secret. There are two ways to authenticate a
request, both resolving to a user in radadmin_admins:
-
Login session (the web UI) —
POST /auth/loginwith{username, password}returns atoken. Send it on every request as:Authorization: Bearer <token> -
API key (scripts / integrations) — an admin mints a key in the UI (
POST /apikeys). The raw key is shown once; send it as:X-API-Key: <key>
Missing/invalid credentials → 401. An action that needs admin rights when
you're a regular user → 403. /, /health, /docs, and POST /auth/login
are open.
Roles
- admin (
is_admin = 1) — everything below, plus manage users, mint/revoke API keys, and read the activity log. - user (regular) — full access to the RADIUS resources (clients, VLANs, devices, …) and can change their own password, but cannot create/delete users, reset anyone else's password, manage API keys, or view the log.
First deploy
The database seeds admin / admin on first run with a forced password change.
Sign in, change the password when prompted, then create the real users.
Auth & admin endpoints
| Path | Method | Who | Purpose |
|---|---|---|---|
/auth/login |
POST | open | {username,password} → {token, username, is_admin, must_change_password} |
/auth/logout |
POST | any | Invalidate the current session token |
/auth/me |
GET | any | Current user {id, username, is_admin, must_change_password} |
/auth/change-password |
POST | any | Change your own password {current_password, new_password} |
/users |
GET | admin | List admin-portal users |
/users |
POST | admin | Create a user {username, password, is_admin, force_password_change} (force_password_change defaults to true) |
/users/{id} |
DELETE | admin | Delete a user (not self, not last admin) |
/users/{id}/reset-password |
POST | admin | Reset another user's password {new_password} |
/apikeys |
GET | admin | List API keys (hashes never returned) |
/apikeys |
POST | admin | Create a key {name} → response includes raw key once |
/apikeys/{id} |
DELETE | admin | Revoke a key |
/logs |
GET | admin | Paginated activity log (?username=&action=) |
Activity log
Every state-changing request (POST/PUT/PATCH/DELETE) and every auth
event (login, logout, password change, user & key management) is recorded to
radadmin_logs with the acting username, action, a detail string, the client IP,
and a timestamp. Admins read it at GET /logs.
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: an auth header — Authorization: Bearer <token> from
POST /auth/login, or X-API-Key: <key> for an admin-issued key (except
/health and /auth/login).
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 | search, status |
| 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}]} |
| Search / filter | GET /client/?search=<term>&status=<new|paid|unpaid> |
| 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. - List accepts two optional filters (both applied before pagination, so
totalreflects the filtered set):searchis a case-insensitive substring matched acrossmac_address,name,phone, andalias;statusis an exact match onnew/paid/unpaid. Combine them freely, e.g.GET /client/?search=ali&status=unpaid. - 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 to match your deployment. The X-API-Key header below stands for an
admin-issued key — or swap any of these for -H "Authorization: Bearer $TOKEN"
using a token from /auth/login.
# Log in (default seeded creds on first deploy: admin / admin)
curl http://10.0.1.235:8000/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin"}' -s | jq
# Change your own password (required after the first login)
curl http://10.0.1.235:8000/auth/change-password \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"current_password":"admin","new_password":"a-better-password"}' -s | jq
# Create a user (admin only)
curl http://10.0.1.235:8000/users \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"username":"ops","password":"changeme","is_admin":false}' -s | jq
# Mint an API key (admin only) — the raw "key" is shown once in the response
curl http://10.0.1.235:8000/apikeys \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"billing-cron"}' -s | jq
# Read the activity log (admin only)
curl http://10.0.1.235:8000/logs?limit=50 \
-H "Authorization: Bearer $TOKEN" -s | jq
# List customers (using an admin-issued API key)
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 credentials (Bearer token or API key) |
| 403 | Authenticated but not an admin for an admin-only action |
| 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, activity-log middleware, CORS
config.py env-driven settings (pydantic-settings)
database.py SQLAlchemy engine/session
auth.py password hashing, sessions, API keys, auth dependencies
bootstrap.py first-run seeding of the default admin/admin account
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_admins/sessions/api_keys/logs)
schemas.py Pydantic request/response models
routers/ one module per resource
auth.py login / logout / me / change-password
users.py admin user management (create/delete/reset-password)
apikeys.py admin API-key management (create/list/revoke)
logs.py admin activity-log view
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, ...