473 lines
21 KiB
Markdown
473 lines
21 KiB
Markdown
# 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 `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=<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, ...
|
||
```
|