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

473 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <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:
```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=<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
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 `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`.
```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, ...
```