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

376 lines
16 KiB
Markdown
Raw 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
- Auth via a shared secret in the **`X-API-Key`** header
## Setup
```bash
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
```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
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:
```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 | — |
| **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.
```bash
# 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, ...
```