# 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 ```bash 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 ```bash 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 ``` 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: ``` 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 ` from `POST /auth/login`, or `X-API-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: ```json { "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: ```json { "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): ```json [ { "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): ```json [ { "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: ```json { "detail": "Client 'AA-BB-CC-DD-EE-11' already exists" } ``` Validation errors (`422`, bad/missing fields) return a FastAPI **array** detail: ```json { "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: ```json { "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=`. | 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}]}` | | Search / filter | `GET /client/?search=&status=` | | 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` (`:`): 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`. ```bash # 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, ... ```