Files
shihaam 4a7de76155
build-and-push / build (push) Failing after 34s
new ui + search clients
2026-08-01 13:34:07 +05:00

21 KiB
Raw Permalink 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
  • Per-user auth: username/password login sessions (Authorization: Bearer) plus admin-issued X-API-Key keys 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.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

Auth is per-user, not a shared secret. There are two ways to authenticate a request, both resolving to a user in radadmin_admins:

  1. Login session (the web UI) — POST /auth/login with {username, password} returns a token. Send it on every request as:

    Authorization: Bearer <token>
    
  2. 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 } ]
  • 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 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.
  • vlanid must be 14094.
  • 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 readsname, 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 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.
  • List accepts two optional filters (both applied before pagination, so total reflects the filtered set): search is a case-insensitive substring matched across mac_address, name, phone, and alias; status is an exact match on new / paid / unpaid. Combine them freely, e.g. GET /client/?search=ali&status=unpaid.
  • 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 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, ...