Files
radadmin/backend/README.md
T
shihaam db140912f5
build-and-push / build (push) Failing after 34s
refactor code, new db tables, show AP info on UI
2026-08-01 02:44:50 +05:00

16 KiB
Raw Blame History

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

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

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 } ]
  • 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:

{ "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/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:

{ "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.
  • 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 (<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.
  • 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.

# 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, ...