From db140912f574ce6de358e41d004a56d24cc285fd Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Sat, 1 Aug 2026 02:44:50 +0500 Subject: [PATCH] refactor code, new db tables, show AP info on UI --- README.md | 2 +- backend/README.md | 148 ++++-- backend/app/main.py | 2 + backend/app/models.py | 27 +- backend/app/routers/client.py | 239 +++++++++ backend/app/routers/device.py | 275 +++------- backend/app/schemas.py | 46 +- frontend/README.md | 28 +- frontend/src/App.tsx | 9 +- ...mportExport.tsx => ClientImportExport.tsx} | 42 +- frontend/src/lib/api.ts | 59 ++- frontend/src/pages/Clients.tsx | 474 ++++++++++++++++++ frontend/src/pages/Devices.tsx | 412 +++------------ 13 files changed, 1103 insertions(+), 660 deletions(-) create mode 100644 backend/app/routers/client.py rename frontend/src/components/{DeviceImportExport.tsx => ClientImportExport.tsx} (89%) create mode 100644 frontend/src/pages/Clients.tsx diff --git a/README.md b/README.md index 7d6ef4f..d8e92b9 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ FreeRADIUS schema and a web admin portal for it. | Dir | What | Stack | |-------------|----------------------------------------|--------------------------------------------| | `backend/` | RESTful CRUD over FreeRADIUS tables | FastAPI, SQLAlchemy 2.0, PyMySQL, Pydantic v2 | -| `frontend/` | Admin UI for devices and VLANs | React 19, Vite, TypeScript, Tailwind v4 | +| `frontend/` | Admin UI for clients, devices and VLANs | React 19, Vite, TypeScript, Tailwind v4 | Auth across both is a shared secret sent in the **`X-API-Key`** header — the frontend collects the key on a login screen and the backend validates it. diff --git a/backend/README.md b/backend/README.md index c0703b8..edb4b82 100644 --- a/backend/README.md +++ b/backend/README.md @@ -4,6 +4,17 @@ 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 @@ -57,8 +68,9 @@ Missing/wrong key → `401`. `/`, `/health`, and `/docs` are open. ## UI integration guide -For a management portal the two primary resources are **Devices** (`/device`) and -**VLANs** (`/vlan`). Everything else is lower-level raw-table access. +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: ` (except `/health`). @@ -66,7 +78,7 @@ For a management portal the two primary resources are **Devices** (`/device`) an ### Response shapes -`GET /device/` — **paginated** envelope: +`GET /client/` — **paginated** envelope: ```json { @@ -80,13 +92,20 @@ For a management portal the two primary resources are **Devices** (`/device`) an } ``` -`GET /device/{mac}`, `POST /device/add`, `POST /device/edit` — a single device object: +`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 @@ -102,7 +121,7 @@ For a management portal the two primary resources are **Devices** (`/device`) an Application errors (`400`, `401`, `404`, `409`) return a **string** detail: ```json -{ "detail": "Device 'AA-BB-CC-DD-EE-11' already exists" } +{ "detail": "Client 'AA-BB-CC-DD-EE-11' already exists" } ``` Validation errors (`422`, bad/missing fields) return a FastAPI **array** detail: @@ -116,12 +135,15 @@ each entry's `msg` (and `loc`) for field-level messages. ### Typical portal flows -- **Provision a device:** `POST /device/add` with `mac_address`, `group` (an +- **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 /device/edit` `{mac_address, status}`. -- **Move to another VLAN:** `POST /device/edit` `{mac_address, group}`. +- **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 @@ -141,7 +163,8 @@ per-resource filters: | Group check | `/radgroupcheck` | GET, POST, PUT, DELETE | `groupname`, `attribute` | | Group reply | `/radgroupreply` | GET, POST, PUT, DELETE | `groupname`, `attribute` | | **VLANs** | `/vlan` | see below | — | -| **Devices** | `/device` | 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` | @@ -169,37 +192,65 @@ and `Tunnel-Private-Group-Id=`. - Renaming updates `radgroupreply.groupname` only; if you also map users to groups in `radusergroup`, update those separately. -### Devices — `/device` +### Clients — `/client` -A device is a client identified by its MAC address (used as the RADIUS `username`). -One device spans three tables: `radcheck` (MAC = password), `radusergroup` (group -membership), and `customers` (status + human metadata). MAC input is normalized to -uppercase, hyphen-separated (`AA-BB-CC-DD-EE-FF`); colons and lowercase are accepted. +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 `customers` table also carries **human-only metadata that RADIUS never reads**: -`name`, `phone`, and `device_alias`. These are collected at device creation. +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 devices | `GET /device/` → `{total,limit,offset,items:[{mac_address,group,status,name,phone,alias}]}` | -| Get one device | `GET /device/{mac_address}` | -| Add a device | `POST /device/add` body `{"mac_address":"14-99-3E-74-CB-7F","group":"staff","name":"Ali Hassan","phone":"7712345","alias":"Living Room TV"}` | -| Edit a device | `POST /device/edit` body `{"mac_address":"...", group?, status?, name?, phone?, alias?}` | -| Delete a device | `DELETE /device/{mac_address}` (removes all 3 rows) | +| 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 `customers.name`, `customers.phone`, `customers.device_alias` and returned -on every device response (`alias` mirrors the `device_alias` column). +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`), and a `customers` row (`status = paid`). The - `group` must already exist in `radgroupreply` or you get `400`. + `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 and - `alias` in `customers.device_alias`. -- Adding a device whose MAC already exists → `409`. + `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` +(`:`): 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 @@ -231,27 +282,44 @@ curl http://10.0.1.235:8000/vlan/55 \ -X DELETE \ -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq -# List devices -curl http://10.0.1.235:8000/device/ \ +# List clients +curl http://10.0.1.235:8000/client/ \ -H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq -# Add a device (MAC + existing group + name/phone required, alias optional) -curl http://10.0.1.235:8000/device/add \ +# 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 device (any subset of group/status/name/phone/alias) -curl http://10.0.1.235:8000/device/edit \ +# 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 -# Delete a device -curl http://10.0.1.235:8000/device/14-99-3E-74-CB-7F \ +# 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" \ @@ -282,7 +350,7 @@ curl http://10.0.1.235:8000/health -s | jq | 204 | Deleted (no content) | | 400 | Bad request (e.g. referenced group/VLAN doesn't exist) | | 401 | Missing/invalid `X-API-Key` | -| 404 | Row / device / VLAN not found | +| 404 | Row / client / device / VLAN not found | | 409 | Duplicate / integrity conflict | | 422 | Request body failed validation | @@ -297,7 +365,11 @@ app/ 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 (one per table) + 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, ... ``` diff --git a/backend/app/main.py b/backend/app/main.py index 2fdabeb..5ec236f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,6 +7,7 @@ from .config import get_settings from .database import engine from .errors import APIError, api_error_handler from .routers import ( + client, customers, device, nas, @@ -69,6 +70,7 @@ for module in ( radgroupreply, radusergroup, vlan, + client, device, radacct, radpostauth, diff --git a/backend/app/models.py b/backend/app/models.py index e7bb36f..198aa6c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -13,9 +13,34 @@ class Customer(Base): username: Mapped[str] = mapped_column(String(64), nullable=False) mac_address: Mapped[str] = mapped_column(String(17), nullable=False) status: Mapped[str] = mapped_column(String(10), nullable=False, default="new") + created_at: Mapped[datetime | None] = mapped_column( + DateTime, nullable=True, server_default=func.current_timestamp() + ) + + +class RadadminClient(Base): + """Human-only client metadata (name/phone/alias), keyed by MAC. RADIUS ignores this.""" + + __tablename__ = "radadmin_clients" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + mac_address: Mapped[str] = mapped_column(String(17), nullable=False, unique=True) name: Mapped[str | None] = mapped_column(String(128), nullable=True) phone: Mapped[str | None] = mapped_column(String(32), nullable=True) - device_alias: Mapped[str | None] = mapped_column(String(64), nullable=True) + alias: Mapped[str | None] = mapped_column(String(64), nullable=True) + created_at: Mapped[datetime | None] = mapped_column( + DateTime, nullable=True, server_default=func.current_timestamp() + ) + + +class RadadminDevice(Base): + """Human alias for a NAS/AP box, keyed by its AP MAC. RADIUS never reads this.""" + + __tablename__ = "radadmin_devices" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + mac_address: Mapped[str] = mapped_column(String(17), nullable=False, unique=True) + alias: Mapped[str | None] = mapped_column(String(64), nullable=True) created_at: Mapped[datetime | None] = mapped_column( DateTime, nullable=True, server_default=func.current_timestamp() ) diff --git a/backend/app/routers/client.py b/backend/app/routers/client.py new file mode 100644 index 0000000..23c5e53 --- /dev/null +++ b/backend/app/routers/client.py @@ -0,0 +1,239 @@ +"""Client management — a logical view spanning three tables. + +A "client" is identified by its MAC address, which is used verbatim as the RADIUS +``username``. One client touches three tables: + + radcheck username = MAC, Cleartext-Password := MAC (auth) + radusergroup username = MAC, groupname = (VLAN/group membership) + customers username = MAC, mac_address = MAC, status (billing/metadata) + +This router hides that fan-out behind mac_address + group + status. +""" +from fastapi import APIRouter, Depends +from pydantic import ValidationError +from sqlalchemy import delete, func, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from ..database import get_db +from ..errors import APIError +from ..models import Customer, RadadminClient, RadCheck, RadGroupReply, RadUserGroup +from ..pagination import Page, PageParams +from ..schemas import ( + ClientCreate, + ClientEdit, + ClientImportError, + ClientImportRequest, + ClientImportResult, + ClientOut, +) + +router = APIRouter(prefix="/client", tags=["client"]) + + +def _group_exists(db: Session, group: str) -> bool: + stmt = select(RadGroupReply.id).where(RadGroupReply.groupname == group).limit(1) + return db.execute(stmt).first() is not None + + +def _client_exists(db: Session, mac: str) -> bool: + stmt = select(Customer.id).where(Customer.username == mac).limit(1) + return db.execute(stmt).first() is not None + + +def _stage_client(db: Session, cli: ClientCreate) -> None: + """Add a client's four rows to the session — radcheck + radusergroup (RADIUS), + customers (status), and radadmin_clients (name/phone/alias metadata). + + Does not commit — the caller controls the transaction boundary. + """ + mac = cli.mac_address + db.add(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac)) + db.add(RadUserGroup(username=mac, groupname=cli.group, priority=1)) + db.add(Customer(username=mac, mac_address=mac, status="paid")) + db.add(RadadminClient(mac_address=mac, name=cli.name, phone=cli.phone, alias=cli.alias)) + + +@router.get("/", response_model=Page[ClientOut]) +def list_clients(page: PageParams = Depends(), db: Session = Depends(get_db)): + """List clients — MAC, group and status, joined from customers + radusergroup.""" + base = ( + select( + Customer.mac_address, RadUserGroup.groupname, Customer.status, + RadadminClient.name, RadadminClient.phone, RadadminClient.alias, + ) + .outerjoin(RadUserGroup, RadUserGroup.username == Customer.username) + .outerjoin(RadadminClient, RadadminClient.mac_address == Customer.username) + ) + total = db.execute(select(func.count()).select_from(Customer)).scalar_one() + rows = db.execute(base.order_by(Customer.id.desc()).limit(page.limit).offset(page.offset)).all() + items = [ + ClientOut(mac_address=mac, group=gn, status=st, name=nm, phone=ph, alias=al) + for mac, gn, st, nm, ph, al in rows + ] + return Page(total=int(total), limit=page.limit, offset=page.offset, items=items) + + +@router.get("/{mac_address}", response_model=ClientOut) +def get_client(mac_address: str, db: Session = Depends(get_db)): + mac = mac_address.strip().upper().replace(":", "-") + stmt = ( + select( + Customer.mac_address, RadUserGroup.groupname, Customer.status, + RadadminClient.name, RadadminClient.phone, RadadminClient.alias, + ) + .outerjoin(RadUserGroup, RadUserGroup.username == Customer.username) + .outerjoin(RadadminClient, RadadminClient.mac_address == Customer.username) + .where(Customer.username == mac) + ) + row = db.execute(stmt).first() + if row is None: + raise APIError(status_code=404, detail=f"Client '{mac}' not found") + return ClientOut( + mac_address=row[0], group=row[1], status=row[2], + name=row[3], phone=row[4], alias=row[5], + ) + + +@router.post("/add", response_model=ClientOut, status_code=201) +def add_client(payload: ClientCreate, db: Session = Depends(get_db)): + """Register a client: create its radcheck, radusergroup and customer rows.""" + mac = payload.mac_address + + if not _group_exists(db, payload.group): + raise APIError(status_code=400, detail=f"Group '{payload.group}' not found — create it first") + if _client_exists(db, mac): + raise APIError(status_code=409, detail=f"Client '{mac}' already exists") + + _stage_client(db, payload) + db.commit() + return ClientOut( + mac_address=mac, group=payload.group, status="paid", + name=payload.name, phone=payload.phone, alias=payload.alias, + ) + + +def _format_validation_error(exc: ValidationError) -> str: + """Turn a pydantic ValidationError into a short, human message.""" + parts = [] + for err in exc.errors(): + loc = ".".join(str(p) for p in err["loc"]) + parts.append(f"{loc}: {err['msg']}" if loc else err["msg"]) + return "; ".join(parts) + + +@router.post("/import", response_model=ClientImportResult) +def import_clients(payload: ClientImportRequest, db: Session = Depends(get_db)): + """Bulk-import clients from parsed CSV rows. + + Every row is validated with the same rules as ``/client/add`` (MAC/phone + normalization, required fields, group-exists, duplicate MAC — both against the + DB and within the file). Errors are collected per row rather than failing the + batch. With ``dry_run`` nothing is written, so the UI can preview and confirm; + otherwise the valid rows are inserted best-effort (each in its own commit). + """ + errors: list[ClientImportError] = [] + valid: list[tuple[int, ClientCreate]] = [] + seen_macs: set[str] = set() + + for idx, raw in enumerate(payload.devices, start=1): + try: + cli = ClientCreate(**raw.model_dump()) + except ValidationError as exc: + errors.append(ClientImportError(row=idx, mac=raw.mac_address, detail=_format_validation_error(exc))) + continue + + mac = cli.mac_address + if mac in seen_macs: + errors.append(ClientImportError(row=idx, mac=mac, detail="Duplicate MAC within file")) + continue + if not _group_exists(db, cli.group): + errors.append(ClientImportError(row=idx, mac=mac, detail=f"Group '{cli.group}' not found")) + continue + if _client_exists(db, mac): + errors.append(ClientImportError(row=idx, mac=mac, detail=f"Client '{mac}' already exists")) + continue + + seen_macs.add(mac) + valid.append((idx, cli)) + + created = 0 + if not payload.dry_run: + for idx, cli in valid: + _stage_client(db, cli) + try: + db.commit() + created += 1 + except IntegrityError: + db.rollback() + errors.append(ClientImportError(row=idx, mac=cli.mac_address, detail="Insert failed (integrity error)")) + + return ClientImportResult( + total=len(payload.devices), + valid=len(valid), + created=created, + dry_run=payload.dry_run, + errors=sorted(errors, key=lambda e: e.row), + ) + + +@router.post("/edit", response_model=ClientOut) +def edit_client(payload: ClientEdit, db: Session = Depends(get_db)): + """Edit a client — any subset of group, status, name, phone, alias.""" + mac = payload.mac_address + customer = db.execute(select(Customer).where(Customer.username == mac)).scalar_one_or_none() + if customer is None: + raise APIError(status_code=404, detail=f"Client '{mac}' not found") + + if payload.group is not None: + if not _group_exists(db, payload.group): + raise APIError(status_code=400, detail=f"Group '{payload.group}' not found") + db.execute( + update(RadUserGroup).where(RadUserGroup.username == mac).values(groupname=payload.group) + ) + + if payload.status is not None: + customer.status = payload.status + + if payload.name is not None or payload.phone is not None or payload.alias is not None: + meta = db.execute( + select(RadadminClient).where(RadadminClient.mac_address == mac) + ).scalar_one_or_none() + if meta is None: + meta = RadadminClient(mac_address=mac) + db.add(meta) + if payload.name is not None: + meta.name = payload.name + if payload.phone is not None: + meta.phone = payload.phone + if payload.alias is not None: + meta.alias = payload.alias + + db.commit() + + group = db.execute( + select(RadUserGroup.groupname).where(RadUserGroup.username == mac).limit(1) + ).scalar_one_or_none() + meta = db.execute( + select(RadadminClient).where(RadadminClient.mac_address == mac) + ).scalar_one_or_none() + return ClientOut( + mac_address=mac, group=group, status=customer.status, + name=meta.name if meta else None, + phone=meta.phone if meta else None, + alias=meta.alias if meta else None, + ) + + +@router.delete("/{mac_address}", status_code=204) +def delete_client(mac_address: str, db: Session = Depends(get_db)): + """Remove a client from radcheck, radusergroup and customers.""" + mac = mac_address.strip().upper().replace(":", "-") + if not _client_exists(db, mac): + raise APIError(status_code=404, detail=f"Client '{mac}' not found") + + db.execute(delete(RadCheck).where(RadCheck.username == mac)) + db.execute(delete(RadUserGroup).where(RadUserGroup.username == mac)) + db.execute(delete(Customer).where(Customer.username == mac)) + db.execute(delete(RadadminClient).where(RadadminClient.mac_address == mac)) + db.commit() diff --git a/backend/app/routers/device.py b/backend/app/routers/device.py index 14ea2c0..07a3f11 100644 --- a/backend/app/routers/device.py +++ b/backend/app/routers/device.py @@ -1,221 +1,96 @@ -"""Device management — a logical view spanning three tables. +"""Device management — the NAS/AP boxes seen in accounting. -A "device" is a client identified by its MAC address, which is used verbatim as -the RADIUS ``username``. One device touches three tables: - - radcheck username = MAC, Cleartext-Password := MAC (auth) - radusergroup username = MAC, groupname = (VLAN/group membership) - customers username = MAC, mac_address = MAC, status (billing/metadata) - -This router hides that fan-out behind mac_address + group + status. +Unlike *clients* (customer MACs), a "device" here is a piece of network gear +identified by its **AP MAC** — the part before ':' in ``radacct.calledstationid`` +(`:`). Rows are grouped by that MAC, so one physical AP is a single +row even when it broadcasts several SSIDs (all SSIDs, and the NAS IP(s) it reports +from, are aggregated). An editable ``alias`` lives in the standalone +``radadmin_devices`` table, keyed by AP MAC — FreeRADIUS never reads it. """ from fastapi import APIRouter, Depends -from pydantic import ValidationError -from sqlalchemy import delete, func, select, update -from sqlalchemy.exc import IntegrityError +from sqlalchemy import select from sqlalchemy.orm import Session from ..database import get_db -from ..errors import APIError -from ..models import Customer, RadCheck, RadGroupReply, RadUserGroup -from ..pagination import Page, PageParams -from ..schemas import ( - DeviceCreate, - DeviceEdit, - DeviceImportError, - DeviceImportRequest, - DeviceImportResult, - DeviceOut, -) +from ..models import RadadminDevice, RadAcct +from ..schemas import DeviceAliasUpdate, DeviceOut router = APIRouter(prefix="/device", tags=["device"]) -def _group_exists(db: Session, group: str) -> bool: - stmt = select(RadGroupReply.id).where(RadGroupReply.groupname == group).limit(1) - return db.execute(stmt).first() is not None +def _norm_mac(mac: str) -> str: + """Canonical AP MAC — uppercase, hyphen-separated (matches client MAC form).""" + return mac.strip().upper().replace(":", "-") -def _device_exists(db: Session, mac: str) -> bool: - stmt = select(Customer.id).where(Customer.username == mac).limit(1) - return db.execute(stmt).first() is not None +def _split_called(called: str | None) -> tuple[str | None, str | None]: + """radacct.calledstationid is ``:`` — split into (mac, ssid). - -def _stage_device(db: Session, dev: DeviceCreate) -> None: - """Add a device's three rows (radcheck, radusergroup, customers) to the session. - - Does not commit — the caller controls the transaction boundary. + The MAC is normalized to uppercase-hyphen; the SSID is left verbatim. """ - mac = dev.mac_address - db.add(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac)) - db.add(RadUserGroup(username=mac, groupname=dev.group, priority=1)) - db.add(Customer( - username=mac, mac_address=mac, status="paid", - name=dev.name, phone=dev.phone, device_alias=dev.alias, - )) + if not called: + return None, None + if ":" in called: + mac, ssid = called.split(":", 1) + return (_norm_mac(mac) or None), (ssid or None) + return _norm_mac(called) or None, None -@router.get("/", response_model=Page[DeviceOut]) -def list_devices(page: PageParams = Depends(), db: Session = Depends(get_db)): - """List devices — MAC, group and status, joined from customers + radusergroup.""" - base = select( - Customer.mac_address, RadUserGroup.groupname, Customer.status, - Customer.name, Customer.phone, Customer.device_alias, - ).outerjoin(RadUserGroup, RadUserGroup.username == Customer.username) - total = db.execute(select(func.count()).select_from(Customer)).scalar_one() - rows = db.execute(base.order_by(Customer.id.desc()).limit(page.limit).offset(page.offset)).all() - items = [ - DeviceOut(mac_address=mac, group=gn, status=st, name=nm, phone=ph, alias=al) - for mac, gn, st, nm, ph, al in rows +def _aliases(db: Session) -> dict[str, str | None]: + rows = db.execute(select(RadadminDevice.mac_address, RadadminDevice.alias)).all() + return {mac: alias for mac, alias in rows} + + +def _devices(db: Session) -> dict[str, dict]: + """Aggregate radacct into {ap_mac: {ips, ssids}} over distinct station pairs.""" + pairs = db.execute( + select(RadAcct.nasipaddress, RadAcct.calledstationid).distinct() + ).all() + devices: dict[str, dict] = {} + for ip, called in pairs: + mac, ssid = _split_called(called) + if not mac: + continue + d = devices.setdefault(mac, {"ips": set(), "ssids": set()}) + if ip: + d["ips"].add(ip) + if ssid: + d["ssids"].add(ssid) + return devices + + +def _device_out(ap_mac: str, agg: dict | None, alias: str | None) -> DeviceOut: + agg = agg or {"ips": set(), "ssids": set()} + return DeviceOut( + ap_mac=ap_mac, + nasipaddress=", ".join(sorted(agg["ips"])) or None, + ssids=sorted(agg["ssids"]), + alias=alias, + ) + + +@router.get("/", response_model=list[DeviceOut]) +def list_devices(db: Session = Depends(get_db)): + """List NAS/AP devices — one row per AP MAC, SSIDs and NAS IPs aggregated.""" + devices = _devices(db) + aliases = _aliases(db) + return [ + _device_out(mac, devices[mac], aliases.get(mac)) + for mac in sorted(devices) ] - return Page(total=int(total), limit=page.limit, offset=page.offset, items=items) -@router.get("/{mac_address}", response_model=DeviceOut) -def get_device(mac_address: str, db: Session = Depends(get_db)): - mac = mac_address.strip().upper().replace(":", "-") - stmt = ( - select( - Customer.mac_address, RadUserGroup.groupname, Customer.status, - Customer.name, Customer.phone, Customer.device_alias, - ) - .outerjoin(RadUserGroup, RadUserGroup.username == Customer.username) - .where(Customer.username == mac) - ) - row = db.execute(stmt).first() - if row is None: - raise APIError(status_code=404, detail=f"Device '{mac}' not found") - return DeviceOut( - mac_address=row[0], group=row[1], status=row[2], - name=row[3], phone=row[4], alias=row[5], - ) - - -@router.post("/add", response_model=DeviceOut, status_code=201) -def add_device(payload: DeviceCreate, db: Session = Depends(get_db)): - """Register a device: create its radcheck, radusergroup and customer rows.""" - mac = payload.mac_address - - if not _group_exists(db, payload.group): - raise APIError(status_code=400, detail=f"Group '{payload.group}' not found — create it first") - if _device_exists(db, mac): - raise APIError(status_code=409, detail=f"Device '{mac}' already exists") - - _stage_device(db, payload) - db.commit() - return DeviceOut( - mac_address=mac, group=payload.group, status="paid", - name=payload.name, phone=payload.phone, alias=payload.alias, - ) - - -def _format_validation_error(exc: ValidationError) -> str: - """Turn a pydantic ValidationError into a short, human message.""" - parts = [] - for err in exc.errors(): - loc = ".".join(str(p) for p in err["loc"]) - parts.append(f"{loc}: {err['msg']}" if loc else err["msg"]) - return "; ".join(parts) - - -@router.post("/import", response_model=DeviceImportResult) -def import_devices(payload: DeviceImportRequest, db: Session = Depends(get_db)): - """Bulk-import devices from parsed CSV rows. - - Every row is validated with the same rules as ``/device/add`` (MAC/phone - normalization, required fields, group-exists, duplicate MAC — both against the - DB and within the file). Errors are collected per row rather than failing the - batch. With ``dry_run`` nothing is written, so the UI can preview and confirm; - otherwise the valid rows are inserted best-effort (each in its own commit). - """ - errors: list[DeviceImportError] = [] - valid: list[tuple[int, DeviceCreate]] = [] - seen_macs: set[str] = set() - - for idx, raw in enumerate(payload.devices, start=1): - try: - dev = DeviceCreate(**raw.model_dump()) - except ValidationError as exc: - errors.append(DeviceImportError(row=idx, mac=raw.mac_address, detail=_format_validation_error(exc))) - continue - - mac = dev.mac_address - if mac in seen_macs: - errors.append(DeviceImportError(row=idx, mac=mac, detail="Duplicate MAC within file")) - continue - if not _group_exists(db, dev.group): - errors.append(DeviceImportError(row=idx, mac=mac, detail=f"Group '{dev.group}' not found")) - continue - if _device_exists(db, mac): - errors.append(DeviceImportError(row=idx, mac=mac, detail=f"Device '{mac}' already exists")) - continue - - seen_macs.add(mac) - valid.append((idx, dev)) - - created = 0 - if not payload.dry_run: - for idx, dev in valid: - _stage_device(db, dev) - try: - db.commit() - created += 1 - except IntegrityError: - db.rollback() - errors.append(DeviceImportError(row=idx, mac=dev.mac_address, detail="Insert failed (integrity error)")) - - return DeviceImportResult( - total=len(payload.devices), - valid=len(valid), - created=created, - dry_run=payload.dry_run, - errors=sorted(errors, key=lambda e: e.row), - ) - - -@router.post("/edit", response_model=DeviceOut) -def edit_device(payload: DeviceEdit, db: Session = Depends(get_db)): - """Edit a device — any subset of group, status, name, phone, alias.""" - mac = payload.mac_address - customer = db.execute(select(Customer).where(Customer.username == mac)).scalar_one_or_none() - if customer is None: - raise APIError(status_code=404, detail=f"Device '{mac}' not found") - - if payload.group is not None: - if not _group_exists(db, payload.group): - raise APIError(status_code=400, detail=f"Group '{payload.group}' not found") - db.execute( - update(RadUserGroup).where(RadUserGroup.username == mac).values(groupname=payload.group) - ) - - if payload.status is not None: - customer.status = payload.status - if payload.name is not None: - customer.name = payload.name - if payload.phone is not None: - customer.phone = payload.phone - if payload.alias is not None: - customer.device_alias = payload.alias - - db.commit() - - group = db.execute( - select(RadUserGroup.groupname).where(RadUserGroup.username == mac).limit(1) +@router.put("/{ap_mac}", response_model=DeviceOut) +def set_alias(ap_mac: str, payload: DeviceAliasUpdate, db: Session = Depends(get_db)): + """Set (or clear) the alias for an AP MAC — upserts into radadmin_devices.""" + mac = _norm_mac(ap_mac) + row = db.execute( + select(RadadminDevice).where(RadadminDevice.mac_address == mac) ).scalar_one_or_none() - return DeviceOut( - mac_address=mac, group=group, status=customer.status, - name=customer.name, phone=customer.phone, alias=customer.device_alias, - ) - - -@router.delete("/{mac_address}", status_code=204) -def delete_device(mac_address: str, db: Session = Depends(get_db)): - """Remove a device from radcheck, radusergroup and customers.""" - mac = mac_address.strip().upper().replace(":", "-") - if not _device_exists(db, mac): - raise APIError(status_code=404, detail=f"Device '{mac}' not found") - - db.execute(delete(RadCheck).where(RadCheck.username == mac)) - db.execute(delete(RadUserGroup).where(RadUserGroup.username == mac)) - db.execute(delete(Customer).where(Customer.username == mac)) + if row is None: + db.add(RadadminDevice(mac_address=mac, alias=payload.alias)) + else: + row.alias = payload.alias db.commit() + + return _device_out(mac, _devices(db).get(mac), payload.alias) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index eeb107f..41a4ca7 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -10,13 +10,12 @@ class ORMModel(BaseModel): # ---------- customers ---------- +# Human metadata (name/phone/alias) lives in radadmin_clients now — customers holds +# only the RADIUS-adjacent identity + billing status. class CustomerBase(BaseModel): username: str = Field(max_length=64) mac_address: str = Field(max_length=17) status: Literal["new", "paid", "unpaid"] = "new" - name: str | None = Field(default=None, max_length=128) - phone: str | None = Field(default=None, max_length=32) - device_alias: str | None = Field(default=None, max_length=64) class CustomerCreate(CustomerBase): @@ -27,9 +26,6 @@ class CustomerUpdate(BaseModel): username: str | None = Field(default=None, max_length=64) mac_address: str | None = Field(default=None, max_length=17) status: Literal["new", "paid", "unpaid"] | None = None - name: str | None = Field(default=None, max_length=128) - phone: str | None = Field(default=None, max_length=32) - device_alias: str | None = Field(default=None, max_length=64) class CustomerOut(ORMModel, CustomerBase): @@ -130,7 +126,7 @@ class VlanEdit(BaseModel): alias: str = Field(min_length=1, max_length=64, description="New alias (groupname)") -# ---------- devices (logical view over radcheck + radusergroup + customers) ---------- +# ---------- clients (logical view over radcheck + radusergroup + customers) ---------- _MAC_RE = r"^[0-9A-Fa-f]{2}([-:][0-9A-Fa-f]{2}){5}$" @@ -157,16 +153,16 @@ def _normalize_phone(raw: str) -> str: raise ValueError(_PHONE_ERROR) -class DeviceOut(BaseModel): +class ClientOut(BaseModel): mac_address: str group: str | None = None # radusergroup.groupname status: str | None = None # customers.status name: str | None = None # customers.name (human metadata) phone: str | None = None # customers.phone (human metadata) - alias: str | None = None # customers.device_alias (human metadata) + alias: str | None = None # radadmin_clients.alias (human metadata) -class DeviceCreate(BaseModel): +class ClientCreate(BaseModel): mac_address: str = Field(pattern=_MAC_RE, max_length=17) group: str = Field(min_length=1, max_length=64) name: str = Field(min_length=1, max_length=128, description="Customer name (metadata, ignored by RADIUS)") @@ -184,7 +180,7 @@ class DeviceCreate(BaseModel): return _normalize_phone(v) -class DeviceEdit(BaseModel): +class ClientEdit(BaseModel): mac_address: str = Field(pattern=_MAC_RE, max_length=17) group: str | None = Field(default=None, max_length=64) status: Literal["new", "paid", "unpaid"] | None = None @@ -209,11 +205,11 @@ class DeviceEdit(BaseModel): return self -# ---------- device CSV import ---------- -class DeviceImportRow(BaseModel): +# ---------- client CSV import ---------- +class ClientImportRow(BaseModel): """A single CSV row — lenient so one bad row never 422s the whole batch. - Each row is re-validated with ``DeviceCreate`` inside the router so failures + Each row is re-validated with ``ClientCreate`` inside the router so failures are collected per-row instead of rejecting the entire request. """ @@ -224,23 +220,35 @@ class DeviceImportRow(BaseModel): alias: str | None = None -class DeviceImportRequest(BaseModel): - devices: list[DeviceImportRow] +class ClientImportRequest(BaseModel): + devices: list[ClientImportRow] dry_run: bool = Field(default=False, description="Validate only; commit nothing") -class DeviceImportError(BaseModel): +class ClientImportError(BaseModel): row: int # 1-based index within the submitted rows mac: str | None = None detail: str -class DeviceImportResult(BaseModel): +class ClientImportResult(BaseModel): total: int # rows submitted valid: int # rows that passed validation created: int # rows actually inserted (0 on dry_run) dry_run: bool - errors: list[DeviceImportError] + errors: list[ClientImportError] + + +# ---------- devices (NAS/AP boxes seen in radacct, aliased via radadmin_devices) ---------- +class DeviceOut(BaseModel): + ap_mac: str # calledstationid before ':' — the device key + nasipaddress: str | None = None # NAS IP(s) this AP reports from (comma-joined) + ssids: list[str] = [] # every SSID seen for this AP MAC + alias: str | None = None # radadmin_devices.alias (human label) + + +class DeviceAliasUpdate(BaseModel): + alias: str | None = Field(default=None, max_length=64) # ---------- radusergroup ---------- diff --git a/frontend/README.md b/frontend/README.md index d79221d..4da4ca2 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,8 +1,9 @@ # radui — RADIUS Admin Portal -Web UI for the [`radapi`](../radapi) FreeRADIUS REST API. Manage **devices** and -**VLANs** without touching SQL. Auth is an **API key** entered on a login screen and -stored in the browser (`localStorage`), sent as `X-API-Key` on every request. +Web UI for the [`radapi`](../radapi) FreeRADIUS REST API. Manage **clients** +(customer MACs), **devices** (NAS/AP boxes seen in accounting), and **VLANs** +without touching SQL. Auth is an **API key** entered on a login screen and stored in +the browser (`localStorage`), sent as `X-API-Key` on every request. ## Stack @@ -55,19 +56,28 @@ src/ main.tsx providers (router, auth, toaster) App.tsx auth gate + nav + routes auth/auth.tsx API-key auth context (login/logout, 401 handling) - lib/api.ts typed API client (device + vlan) + error handling + lib/api.ts typed API client (client + device + vlan) + error handling lib/utils.ts cn() helper pages/ Login.tsx API-key login screen - Devices.tsx device list + add/edit/delete dialogs + Clients.tsx client list + add/edit/delete + CSV import/export dialogs + Devices.tsx NAS device list + editable alias Vlans.tsx VLAN list + add/rename/delete dialogs - components/ui/ button, input, label, dialog, select, table, card, badge, sonner + components/ + ClientImportExport.tsx CSV import (preview/confirm) + export dialog + ui/ button, input, label, dialog, select, table, card, badge, sonner ``` ## Notes -- **Add device** requires MAC, group (VLAN), name, phone; alias optional. The group - dropdown is populated from `GET /vlan/`. -- **Edit device** can change group, status, name, phone, alias (any subset). +- **Clients** — add requires MAC, group (VLAN), name, phone; alias optional. The + group dropdown is populated from `GET /vlan/`. Edit changes any subset of group, + status, name, phone, alias. +- **CSV import** parses the file client-side, does a dry-run against + `POST /client/import` to preview valid/error rows, and only writes on confirm. + Export downloads all clients as CSV. +- **Devices** — lists NAS/AP boxes from accounting, one row per **AP MAC** (with its + NAS IP and all SSIDs — extra SSIDs collapse to `SSID1 (and N more…)` on hover). The + only editable field is the human `alias` (`PUT /device/{ap_mac}`). - Validation/`4xx` errors from the API surface as toasts with the server's message (422 field errors are flattened to `field: message`). diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8cb108a..51a4c49 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,9 @@ import { NavLink, Navigate, Route, Routes } from 'react-router-dom' -import { LogOut, Moon, Router, Sun, Wifi } from 'lucide-react' +import { LogOut, Moon, Router, Sun, Users, Wifi } from 'lucide-react' import { useAuth } from '@/auth/auth' import { useTheme } from '@/lib/theme' import { Login } from '@/pages/Login' +import { Clients } from '@/pages/Clients' import { Devices } from '@/pages/Devices' import { Vlans } from '@/pages/Vlans' import { Button } from '@/components/ui/button' @@ -39,6 +40,9 @@ function Nav() { RADIUS Admin + + Clients + Devices @@ -66,9 +70,10 @@ export default function App() {