restructure: move backend into backend/

This commit is contained in:
2026-07-31 23:53:50 +05:00
parent 9074e55a13
commit 79bc04aaa7
29 changed files with 0 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# --- Database (FreeRADIUS) ---
DB_HOST=192.168.1.21
DB_PORT=3306
DB_USER=root
DB_PASSWORD=changeme
DB_NAME=radius
# --- API ---
# Shared secret required in the X-API-Key header on every request.
API_KEY=change-this-to-a-long-random-string
# Comma-separated CORS origins (use * only for trusted/dev networks)
CORS_ORIGINS=*
# Default page size cap for list endpoints
DEFAULT_LIMIT=50
MAX_LIMIT=500
+8
View File
@@ -0,0 +1,8 @@
venv/*
.env
tmp/
.build/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
+303
View File
@@ -0,0 +1,303 @@
# 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`).
## 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 two primary resources are **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 /device/`**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 /device/{mac}`, `POST /device/add`, `POST /device/edit` — a single device object:
```json
{ "mac_address": "AA-BB-CC-DD-EE-11", "group": "residents", "status": "paid",
"name": "Sara Ib", "phone": "9998887", "alias": "Living Room TV" }
```
`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": "Device '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 device:** `POST /device/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}`.
- **Populate a VLAN dropdown:** `GET /vlan/` → map `alias` (value sent as `group`).
## 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 | — |
| **Devices** | `/device` | see below | — |
| 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.
### Devices — `/device`
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.
The `customers` table also carries **human-only metadata that RADIUS never reads**:
`name`, `phone`, and `device_alias`. These are collected at device 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) |
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).
- **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`.
- **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`.
## 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 devices
curl http://10.0.1.235:8000/device/ \
-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 \
-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 \
-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 \
-X DELETE \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -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 / 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 (one per table)
schemas.py Pydantic request/response models
routers/ one module per resource
```
View File
+15
View File
@@ -0,0 +1,15 @@
import secrets
from fastapi import Security
from fastapi.security import APIKeyHeader
from .config import get_settings
from .errors import APIError
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
def require_api_key(api_key: str | None = Security(api_key_header)) -> None:
expected = get_settings().api_key
if not api_key or not secrets.compare_digest(api_key, expected):
raise APIError(status_code=401, detail="Invalid or missing API key")
+36
View File
@@ -0,0 +1,36 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
db_host: str = "127.0.0.1"
db_port: int = 3306
db_user: str = "root"
db_password: str = ""
db_name: str = "radius"
api_key: str = "change-me"
cors_origins: str = "*"
default_limit: int = 50
max_limit: int = 500
@property
def database_url(self) -> str:
from urllib.parse import quote_plus
return (
f"mysql+pymysql://{self.db_user}:{quote_plus(self.db_password)}"
f"@{self.db_host}:{self.db_port}/{self.db_name}?charset=utf8mb4"
)
@property
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
@lru_cache
def get_settings() -> Settings:
return Settings()
+79
View File
@@ -0,0 +1,79 @@
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .database import Base
from .errors import APIError
def list_rows(
db: Session,
model: type[Base],
*,
limit: int,
offset: int,
filters: dict[str, Any] | None = None,
order_by: Any | None = None,
) -> list[Base]:
stmt = select(model)
for field, value in (filters or {}).items():
if value is not None:
stmt = stmt.where(getattr(model, field) == value)
if order_by is not None:
stmt = stmt.order_by(order_by)
stmt = stmt.limit(limit).offset(offset)
return list(db.execute(stmt).scalars().all())
def count_rows(db: Session, model: type[Base], filters: dict[str, Any] | None = None) -> int:
stmt = select(func.count()).select_from(model)
for field, value in (filters or {}).items():
if value is not None:
stmt = stmt.where(getattr(model, field) == value)
return int(db.execute(stmt).scalar_one())
def get_row_or_404(db: Session, model: type[Base], pk: Any, pk_field: str = "id") -> Base:
obj = db.execute(select(model).where(getattr(model, pk_field) == pk)).scalar_one_or_none()
if obj is None:
raise APIError(status_code=404, detail=f"{model.__tablename__} '{pk}' not found")
return obj
def create_row(db: Session, model: type[Base], data: dict[str, Any]) -> Base:
obj = model(**data)
db.add(obj)
_commit(db)
db.refresh(obj)
return obj
def update_row(db: Session, obj: Base, data: dict[str, Any]) -> Base:
for field, value in data.items():
setattr(obj, field, value)
_commit(db)
db.refresh(obj)
return obj
def delete_row(db: Session, obj: Base) -> None:
db.delete(obj)
_commit(db)
def _commit(db: Session) -> None:
from sqlalchemy.exc import IntegrityError
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise APIError(status_code=409, detail=_integrity_message(exc)) from exc
def _integrity_message(exc: Exception) -> str:
msg = str(getattr(exc, "orig", exc))
if "Duplicate entry" in msg:
return "Duplicate entry: a record with these unique values already exists"
return "Database integrity error"
+29
View File
@@ -0,0 +1,29 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from .config import get_settings
settings = get_settings()
engine = create_engine(
settings.database_url,
pool_pre_ping=True,
pool_recycle=3600,
future=True,
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
class Base(DeclarativeBase):
pass
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
+15
View File
@@ -0,0 +1,15 @@
from fastapi import Request
from fastapi.responses import JSONResponse
class APIError(Exception):
"""Application error that maps to a JSON response with a status code."""
def __init__(self, status_code: int, detail: str):
self.status_code = status_code
self.detail = detail
super().__init__(detail)
async def api_error_handler(request: Request, exc: APIError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
+77
View File
@@ -0,0 +1,77 @@
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from .auth import require_api_key
from .config import get_settings
from .database import engine
from .errors import APIError, api_error_handler
from .routers import (
customers,
device,
nas,
nasreload,
radacct,
radcheck,
radgroupcheck,
radgroupreply,
radpostauth,
radreply,
radusergroup,
vlan,
)
settings = get_settings()
app = FastAPI(
title="FreeRADIUS REST API",
description="RESTful CRUD over the FreeRADIUS SQL schema. "
"All endpoints require the `X-API-Key` header.",
version="1.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_exception_handler(APIError, api_error_handler)
# ---- unauthenticated meta endpoints ----
@app.get("/", tags=["meta"])
def root():
return {"service": "freeradius-rest-api", "docs": "/docs", "health": "/health"}
@app.get("/health", tags=["meta"])
def health():
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
db_ok = True
except Exception:
db_ok = False
return {"status": "ok" if db_ok else "degraded", "database": "up" if db_ok else "down"}
# ---- authenticated resource routers ----
protected = [Depends(require_api_key)]
for module in (
customers,
nas,
radcheck,
radreply,
radgroupcheck,
radgroupreply,
radusergroup,
vlan,
device,
radacct,
radpostauth,
nasreload,
):
app.include_router(module.router, dependencies=protected)
+136
View File
@@ -0,0 +1,136 @@
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from .database import Base
class Customer(Base):
__tablename__ = "customers"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
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")
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)
created_at: Mapped[datetime | None] = mapped_column(
DateTime, nullable=True, server_default=func.current_timestamp()
)
class Nas(Base):
__tablename__ = "nas"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
nasname: Mapped[str] = mapped_column(String(128), nullable=False)
shortname: Mapped[str | None] = mapped_column(String(32), nullable=True)
type: Mapped[str | None] = mapped_column(String(30), nullable=True, default="other")
ports: Mapped[int | None] = mapped_column(Integer, nullable=True)
secret: Mapped[str] = mapped_column(String(60), nullable=False, default="secret")
server: Mapped[str | None] = mapped_column(String(64), nullable=True)
community: Mapped[str | None] = mapped_column(String(50), nullable=True)
description: Mapped[str | None] = mapped_column(String(200), nullable=True, default="RADIUS Client")
class NasReload(Base):
__tablename__ = "nasreload"
nasipaddress: Mapped[str] = mapped_column(String(15), primary_key=True)
reloadtime: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class RadCheck(Base):
__tablename__ = "radcheck"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), nullable=False, default="")
attribute: Mapped[str] = mapped_column(String(64), nullable=False, default="")
op: Mapped[str] = mapped_column(String(2), nullable=False, default="==")
value: Mapped[str] = mapped_column(String(253), nullable=False, default="")
class RadReply(Base):
__tablename__ = "radreply"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), nullable=False, default="")
attribute: Mapped[str] = mapped_column(String(64), nullable=False, default="")
op: Mapped[str] = mapped_column(String(2), nullable=False, default="=")
value: Mapped[str] = mapped_column(String(253), nullable=False, default="")
class RadGroupCheck(Base):
__tablename__ = "radgroupcheck"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
groupname: Mapped[str] = mapped_column(String(64), nullable=False, default="")
attribute: Mapped[str] = mapped_column(String(64), nullable=False, default="")
op: Mapped[str] = mapped_column(String(2), nullable=False, default="==")
value: Mapped[str] = mapped_column(String(253), nullable=False, default="")
class RadGroupReply(Base):
__tablename__ = "radgroupreply"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
groupname: Mapped[str] = mapped_column(String(64), nullable=False, default="")
attribute: Mapped[str] = mapped_column(String(64), nullable=False, default="")
op: Mapped[str] = mapped_column(String(2), nullable=False, default="=")
value: Mapped[str] = mapped_column(String(253), nullable=False, default="")
class RadUserGroup(Base):
__tablename__ = "radusergroup"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), nullable=False, default="")
groupname: Mapped[str] = mapped_column(String(64), nullable=False, default="")
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
class RadAcct(Base):
__tablename__ = "radacct"
radacctid: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
acctsessionid: Mapped[str] = mapped_column(String(64), nullable=False, default="")
acctuniqueid: Mapped[str] = mapped_column(String(32), nullable=False, default="")
username: Mapped[str] = mapped_column(String(64), nullable=False, default="")
realm: Mapped[str | None] = mapped_column(String(64), nullable=True, default="")
nasipaddress: Mapped[str] = mapped_column(String(15), nullable=False, default="")
nasportid: Mapped[str | None] = mapped_column(String(32), nullable=True)
nasporttype: Mapped[str | None] = mapped_column(String(32), nullable=True)
acctstarttime: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
acctupdatetime: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
acctstoptime: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
acctinterval: Mapped[int | None] = mapped_column(Integer, nullable=True)
acctsessiontime: Mapped[int | None] = mapped_column(Integer, nullable=True)
acctauthentic: Mapped[str | None] = mapped_column(String(32), nullable=True)
connectinfo_start: Mapped[str | None] = mapped_column(String(128), nullable=True)
connectinfo_stop: Mapped[str | None] = mapped_column(String(128), nullable=True)
acctinputoctets: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
acctoutputoctets: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
calledstationid: Mapped[str] = mapped_column(String(50), nullable=False, default="")
callingstationid: Mapped[str] = mapped_column(String(50), nullable=False, default="")
acctterminatecause: Mapped[str] = mapped_column(String(32), nullable=False, default="")
servicetype: Mapped[str | None] = mapped_column(String(32), nullable=True)
framedprotocol: Mapped[str | None] = mapped_column(String(32), nullable=True)
framedipaddress: Mapped[str] = mapped_column(String(15), nullable=False, default="")
framedipv6address: Mapped[str] = mapped_column(String(45), nullable=False, default="")
framedipv6prefix: Mapped[str] = mapped_column(String(45), nullable=False, default="")
framedinterfaceid: Mapped[str] = mapped_column(String(44), nullable=False, default="")
delegatedipv6prefix: Mapped[str] = mapped_column(String(45), nullable=False, default="")
class_: Mapped[str | None] = mapped_column("class", String(64), nullable=True)
class RadPostAuth(Base):
__tablename__ = "radpostauth"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), nullable=False, default="")
pass_: Mapped[str] = mapped_column("pass", String(64), nullable=False, default="")
reply: Mapped[str] = mapped_column(String(32), nullable=False, default="")
authdate: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
class_: Mapped[str | None] = mapped_column("class", String(64), nullable=True)
+30
View File
@@ -0,0 +1,30 @@
from typing import Generic, TypeVar
from fastapi import Query
from pydantic import BaseModel
from .config import get_settings
T = TypeVar("T")
class Page(BaseModel, Generic[T]):
total: int
limit: int
offset: int
items: list[T]
class PageParams:
"""Reusable dependency for `?limit=&offset=` with env-configured caps."""
def __init__(
self,
limit: int = Query(default=None, ge=1, description="Max rows to return"),
offset: int = Query(default=0, ge=0, description="Rows to skip"),
):
settings = get_settings()
if limit is None:
limit = settings.default_limit
self.limit = min(limit, settings.max_limit)
self.offset = offset
View File
+72
View File
@@ -0,0 +1,72 @@
"""Router factories for the AVP-style tables (radcheck/radreply/radgroup*).
They share the same shape — an id plus (username|groupname, attribute, op, value).
Two factories cover the two owner shapes: user-owned (radcheck, radreply) and
group-owned (radgroupcheck, radgroupreply).
"""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .. import crud
from ..database import Base, get_db
from ..pagination import Page, PageParams
def build_user_attr_router(*, model, prefix, tag, out_schema, create_schema, update_schema) -> APIRouter:
router = APIRouter(prefix=prefix, tags=[tag])
@router.get("", response_model=Page[out_schema])
def list_items(
page: PageParams = Depends(),
username: str | None = None,
attribute: str | None = None,
db: Session = Depends(get_db),
):
filters = {"username": username, "attribute": attribute}
total = crud.count_rows(db, model, filters)
rows = crud.list_rows(db, model, limit=page.limit, offset=page.offset,
filters=filters, order_by=model.id.asc())
return Page(total=total, limit=page.limit, offset=page.offset, items=rows)
_register_item_routes(router, model, out_schema, create_schema, update_schema)
return router
def build_group_attr_router(*, model, prefix, tag, out_schema, create_schema, update_schema) -> APIRouter:
router = APIRouter(prefix=prefix, tags=[tag])
@router.get("", response_model=Page[out_schema])
def list_items(
page: PageParams = Depends(),
groupname: str | None = None,
attribute: str | None = None,
db: Session = Depends(get_db),
):
filters = {"groupname": groupname, "attribute": attribute}
total = crud.count_rows(db, model, filters)
rows = crud.list_rows(db, model, limit=page.limit, offset=page.offset,
filters=filters, order_by=model.id.asc())
return Page(total=total, limit=page.limit, offset=page.offset, items=rows)
_register_item_routes(router, model, out_schema, create_schema, update_schema)
return router
def _register_item_routes(router: APIRouter, model: type[Base], out_schema, create_schema, update_schema) -> None:
@router.get("/{item_id}", response_model=out_schema)
def get_item(item_id: int, db: Session = Depends(get_db)):
return crud.get_row_or_404(db, model, item_id)
@router.post("", response_model=out_schema, status_code=201)
def create_item(payload: create_schema, db: Session = Depends(get_db)):
return crud.create_row(db, model, payload.model_dump())
@router.put("/{item_id}", response_model=out_schema)
def update_item(item_id: int, payload: update_schema, db: Session = Depends(get_db)):
obj = crud.get_row_or_404(db, model, item_id)
return crud.update_row(db, obj, payload.model_dump(exclude_unset=True))
@router.delete("/{item_id}", status_code=204)
def delete_item(item_id: int, db: Session = Depends(get_db)):
obj = crud.get_row_or_404(db, model, item_id)
crud.delete_row(db, obj)
+47
View File
@@ -0,0 +1,47 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .. import crud
from ..database import get_db
from ..models import Customer
from ..pagination import Page, PageParams
from ..schemas import CustomerCreate, CustomerOut, CustomerUpdate
router = APIRouter(prefix="/customers", tags=["customers"])
@router.get("", response_model=Page[CustomerOut])
def list_customers(
page: PageParams = Depends(),
username: str | None = None,
mac_address: str | None = None,
status: str | None = None,
db: Session = Depends(get_db),
):
filters = {"username": username, "mac_address": mac_address, "status": status}
total = crud.count_rows(db, Customer, filters)
rows = crud.list_rows(db, Customer, limit=page.limit, offset=page.offset, filters=filters,
order_by=Customer.id.desc())
return Page(total=total, limit=page.limit, offset=page.offset, items=rows)
@router.get("/{customer_id}", response_model=CustomerOut)
def get_customer(customer_id: int, db: Session = Depends(get_db)):
return crud.get_row_or_404(db, Customer, customer_id)
@router.post("", response_model=CustomerOut, status_code=201)
def create_customer(payload: CustomerCreate, db: Session = Depends(get_db)):
return crud.create_row(db, Customer, payload.model_dump())
@router.put("/{customer_id}", response_model=CustomerOut)
def update_customer(customer_id: int, payload: CustomerUpdate, db: Session = Depends(get_db)):
obj = crud.get_row_or_404(db, Customer, customer_id)
return crud.update_row(db, obj, payload.model_dump(exclude_unset=True))
@router.delete("/{customer_id}", status_code=204)
def delete_customer(customer_id: int, db: Session = Depends(get_db)):
obj = crud.get_row_or_404(db, Customer, customer_id)
crud.delete_row(db, obj)
+139
View File
@@ -0,0 +1,139 @@
"""Device management — a logical view spanning three tables.
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 = <group> (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 sqlalchemy import delete, func, select, update
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, 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 _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
@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
]
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")
db.add(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac))
db.add(RadUserGroup(username=mac, groupname=payload.group, priority=1))
db.add(Customer(
username=mac, mac_address=mac, status="paid",
name=payload.name, phone=payload.phone, device_alias=payload.alias,
))
db.commit()
return DeviceOut(
mac_address=mac, group=payload.group, status="paid",
name=payload.name, phone=payload.phone, alias=payload.alias,
)
@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)
).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))
db.commit()
+46
View File
@@ -0,0 +1,46 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .. import crud
from ..database import get_db
from ..models import Nas
from ..pagination import Page, PageParams
from ..schemas import NasCreate, NasOut, NasUpdate
router = APIRouter(prefix="/nas", tags=["nas"])
@router.get("", response_model=Page[NasOut])
def list_nas(
page: PageParams = Depends(),
nasname: str | None = None,
shortname: str | None = None,
db: Session = Depends(get_db),
):
filters = {"nasname": nasname, "shortname": shortname}
total = crud.count_rows(db, Nas, filters)
rows = crud.list_rows(db, Nas, limit=page.limit, offset=page.offset, filters=filters,
order_by=Nas.id.asc())
return Page(total=total, limit=page.limit, offset=page.offset, items=rows)
@router.get("/{nas_id}", response_model=NasOut)
def get_nas(nas_id: int, db: Session = Depends(get_db)):
return crud.get_row_or_404(db, Nas, nas_id)
@router.post("", response_model=NasOut, status_code=201)
def create_nas(payload: NasCreate, db: Session = Depends(get_db)):
return crud.create_row(db, Nas, payload.model_dump())
@router.put("/{nas_id}", response_model=NasOut)
def update_nas(nas_id: int, payload: NasUpdate, db: Session = Depends(get_db)):
obj = crud.get_row_or_404(db, Nas, nas_id)
return crud.update_row(db, obj, payload.model_dump(exclude_unset=True))
@router.delete("/{nas_id}", status_code=204)
def delete_nas(nas_id: int, db: Session = Depends(get_db)):
obj = crud.get_row_or_404(db, Nas, nas_id)
crud.delete_row(db, obj)
+24
View File
@@ -0,0 +1,24 @@
"""nasreload — last reload time per NAS. Read-only."""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .. import crud
from ..database import get_db
from ..models import NasReload
from ..pagination import Page, PageParams
from ..schemas import NasReloadOut
router = APIRouter(prefix="/nasreload", tags=["nasreload (read-only)"])
@router.get("", response_model=Page[NasReloadOut])
def list_nasreload(page: PageParams = Depends(), db: Session = Depends(get_db)):
total = crud.count_rows(db, NasReload)
rows = crud.list_rows(db, NasReload, limit=page.limit, offset=page.offset,
order_by=NasReload.reloadtime.desc())
return Page(total=total, limit=page.limit, offset=page.offset, items=rows)
@router.get("/{nasipaddress}", response_model=NasReloadOut)
def get_nasreload(nasipaddress: str, db: Session = Depends(get_db)):
return crud.get_row_or_404(db, NasReload, nasipaddress, pk_field="nasipaddress")
+41
View File
@@ -0,0 +1,41 @@
"""radacct — accounting data. Read-only: FreeRADIUS owns writes to this table."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import crud
from ..database import get_db
from ..models import RadAcct
from ..pagination import Page, PageParams
from ..schemas import RadAcctOut
router = APIRouter(prefix="/radacct", tags=["radacct (read-only)"])
@router.get("", response_model=Page[RadAcctOut])
def list_acct(
page: PageParams = Depends(),
username: str | None = None,
nasipaddress: str | None = None,
active: bool | None = Query(default=None, description="true = sessions with no stop time"),
db: Session = Depends(get_db),
):
filters = {"username": username, "nasipaddress": nasipaddress}
total = crud.count_rows(db, RadAcct, filters)
stmt = select(RadAcct)
for field, value in filters.items():
if value is not None:
stmt = stmt.where(getattr(RadAcct, field) == value)
if active is True:
stmt = stmt.where(RadAcct.acctstoptime.is_(None))
elif active is False:
stmt = stmt.where(RadAcct.acctstoptime.is_not(None))
stmt = stmt.order_by(RadAcct.acctstarttime.desc()).limit(page.limit).offset(page.offset)
rows = list(db.execute(stmt).scalars().all())
return Page(total=total, limit=page.limit, offset=page.offset, items=rows)
@router.get("/{radacctid}", response_model=RadAcctOut)
def get_acct(radacctid: int, db: Session = Depends(get_db)):
return crud.get_row_or_404(db, RadAcct, radacctid, pk_field="radacctid")
+12
View File
@@ -0,0 +1,12 @@
from ..models import RadCheck
from ..schemas import UserAttrCreate, UserAttrOut, UserAttrUpdate
from ._attr_factory import build_user_attr_router
router = build_user_attr_router(
model=RadCheck,
prefix="/radcheck",
tag="radcheck",
out_schema=UserAttrOut,
create_schema=UserAttrCreate,
update_schema=UserAttrUpdate,
)
+12
View File
@@ -0,0 +1,12 @@
from ..models import RadGroupCheck
from ..schemas import GroupAttrCreate, GroupAttrOut, GroupAttrUpdate
from ._attr_factory import build_group_attr_router
router = build_group_attr_router(
model=RadGroupCheck,
prefix="/radgroupcheck",
tag="radgroupcheck",
out_schema=GroupAttrOut,
create_schema=GroupAttrCreate,
update_schema=GroupAttrUpdate,
)
+17
View File
@@ -0,0 +1,17 @@
"""radgroupreply — raw group reply attributes (all attributes, not just VLAN).
The VLAN-specific abstraction lives in ``routers/vlan.py`` (``/vlan``); this router
stays as generic CRUD over the raw table for any other reply attributes.
"""
from ..models import RadGroupReply
from ..schemas import GroupAttrCreate, GroupAttrOut, GroupAttrUpdate
from ._attr_factory import build_group_attr_router
router = build_group_attr_router(
model=RadGroupReply,
prefix="/radgroupreply",
tag="radgroupreply",
out_schema=GroupAttrOut,
create_schema=GroupAttrCreate,
update_schema=GroupAttrUpdate,
)
+30
View File
@@ -0,0 +1,30 @@
"""radpostauth — authentication log. Read-only."""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .. import crud
from ..database import get_db
from ..models import RadPostAuth
from ..pagination import Page, PageParams
from ..schemas import RadPostAuthOut
router = APIRouter(prefix="/radpostauth", tags=["radpostauth (read-only)"])
@router.get("", response_model=Page[RadPostAuthOut])
def list_postauth(
page: PageParams = Depends(),
username: str | None = None,
reply: str | None = None,
db: Session = Depends(get_db),
):
filters = {"username": username, "reply": reply}
total = crud.count_rows(db, RadPostAuth, filters)
rows = crud.list_rows(db, RadPostAuth, limit=page.limit, offset=page.offset,
filters=filters, order_by=RadPostAuth.id.desc())
return Page(total=total, limit=page.limit, offset=page.offset, items=rows)
@router.get("/{item_id}", response_model=RadPostAuthOut)
def get_postauth(item_id: int, db: Session = Depends(get_db)):
return crud.get_row_or_404(db, RadPostAuth, item_id)
+12
View File
@@ -0,0 +1,12 @@
from ..models import RadReply
from ..schemas import UserAttrCreate, UserAttrOut, UserAttrUpdate
from ._attr_factory import build_user_attr_router
router = build_user_attr_router(
model=RadReply,
prefix="/radreply",
tag="radreply",
out_schema=UserAttrOut,
create_schema=UserAttrCreate,
update_schema=UserAttrUpdate,
)
+46
View File
@@ -0,0 +1,46 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .. import crud
from ..database import get_db
from ..models import RadUserGroup
from ..pagination import Page, PageParams
from ..schemas import UserGroupCreate, UserGroupOut, UserGroupUpdate
router = APIRouter(prefix="/radusergroup", tags=["radusergroup"])
@router.get("", response_model=Page[UserGroupOut])
def list_usergroups(
page: PageParams = Depends(),
username: str | None = None,
groupname: str | None = None,
db: Session = Depends(get_db),
):
filters = {"username": username, "groupname": groupname}
total = crud.count_rows(db, RadUserGroup, filters)
rows = crud.list_rows(db, RadUserGroup, limit=page.limit, offset=page.offset,
filters=filters, order_by=RadUserGroup.priority.asc())
return Page(total=total, limit=page.limit, offset=page.offset, items=rows)
@router.get("/{item_id}", response_model=UserGroupOut)
def get_usergroup(item_id: int, db: Session = Depends(get_db)):
return crud.get_row_or_404(db, RadUserGroup, item_id)
@router.post("", response_model=UserGroupOut, status_code=201)
def create_usergroup(payload: UserGroupCreate, db: Session = Depends(get_db)):
return crud.create_row(db, RadUserGroup, payload.model_dump())
@router.put("/{item_id}", response_model=UserGroupOut)
def update_usergroup(item_id: int, payload: UserGroupUpdate, db: Session = Depends(get_db)):
obj = crud.get_row_or_404(db, RadUserGroup, item_id)
return crud.update_row(db, obj, payload.model_dump(exclude_unset=True))
@router.delete("/{item_id}", status_code=204)
def delete_usergroup(item_id: int, db: Session = Depends(get_db)):
obj = crud.get_row_or_404(db, RadUserGroup, item_id)
crud.delete_row(db, obj)
+112
View File
@@ -0,0 +1,112 @@
"""VLAN management — a logical view over the ``radgroupreply`` table.
A single "VLAN" is stored as three reply rows sharing one ``groupname`` (the alias):
groupname | attribute | op | value
----------+--------------------------+----+----------
staff | Tunnel-Type | = | VLAN
staff | Tunnel-Medium-Type | = | IEEE-802
staff | Tunnel-Private-Group-Id | = | 55 <- the VLAN ID
This router hides that shape behind alias + vlanid.
"""
from fastapi import APIRouter, Depends
from sqlalchemy import delete, select, update
from sqlalchemy.orm import Session
from ..database import get_db
from ..errors import APIError
from ..models import RadGroupReply
from ..schemas import VlanCreate, VlanEdit, VlanOut
router = APIRouter(prefix="/vlan", tags=["vlan"])
ID_ATTR = "Tunnel-Private-Group-Id"
OP = "="
# (attribute, value) pairs written for every VLAN. `None` value = the VLAN ID.
VLAN_ROWS: list[tuple[str, str | None]] = [
("Tunnel-Type", "VLAN"),
("Tunnel-Medium-Type", "IEEE-802"),
(ID_ATTR, None),
]
def _groupnames_for_vlanid(db: Session, vlanid: int) -> list[str]:
stmt = select(RadGroupReply.groupname).where(
RadGroupReply.attribute == ID_ATTR,
RadGroupReply.value == str(vlanid),
)
return list(db.execute(stmt).scalars().all())
def _alias_exists(db: Session, alias: str) -> bool:
stmt = select(RadGroupReply.id).where(RadGroupReply.groupname == alias).limit(1)
return db.execute(stmt).first() is not None
@router.get("/", response_model=list[VlanOut])
def list_vlans(db: Session = Depends(get_db)):
"""List VLANs — one entry per group, showing only alias + VLAN ID."""
stmt = (
select(RadGroupReply.groupname, RadGroupReply.value)
.where(RadGroupReply.attribute == ID_ATTR)
.order_by(RadGroupReply.value.asc())
)
rows = db.execute(stmt).all()
return [VlanOut(alias=gn, vlanid=int(val)) for gn, val in rows]
@router.post("/add", response_model=VlanOut, status_code=201)
def add_vlan(payload: VlanCreate, db: Session = Depends(get_db)):
"""Create a VLAN — inserts the 3 radgroupreply rows atomically."""
# reject duplicate VLAN ID or duplicate alias
if _groupnames_for_vlanid(db, payload.vlanid):
raise APIError(status_code=409, detail=f"VLAN ID {payload.vlanid} already exists")
if _alias_exists(db, payload.alias):
raise APIError(status_code=409, detail=f"Alias '{payload.alias}' already exists")
for attribute, value in VLAN_ROWS:
db.add(RadGroupReply(
groupname=payload.alias,
attribute=attribute,
op=OP,
value=str(payload.vlanid) if value is None else value,
))
db.commit()
return VlanOut(alias=payload.alias, vlanid=payload.vlanid)
@router.post("/edit", response_model=VlanOut)
def edit_vlan_alias(payload: VlanEdit, db: Session = Depends(get_db)):
"""Rename a VLAN's alias (groupname), identified by its VLAN ID."""
groupnames = _groupnames_for_vlanid(db, payload.vlanid)
if not groupnames:
raise APIError(status_code=404, detail=f"VLAN ID {payload.vlanid} not found")
current = groupnames[0]
if payload.alias == current:
return VlanOut(alias=current, vlanid=payload.vlanid)
# new alias must not collide with a different group
if _alias_exists(db, payload.alias):
raise APIError(status_code=409, detail=f"Alias '{payload.alias}' already exists")
db.execute(
update(RadGroupReply)
.where(RadGroupReply.groupname == current)
.values(groupname=payload.alias)
)
db.commit()
return VlanOut(alias=payload.alias, vlanid=payload.vlanid)
@router.delete("/{vlanid}", status_code=204)
def delete_vlan(vlanid: int, db: Session = Depends(get_db)):
"""Delete a VLAN — removes every radgroupreply row for the matching group(s)."""
groupnames = _groupnames_for_vlanid(db, vlanid)
if not groupnames:
raise APIError(status_code=404, detail=f"VLAN ID {vlanid} not found")
db.execute(delete(RadGroupReply).where(RadGroupReply.groupname.in_(groupnames)))
db.commit()
+274
View File
@@ -0,0 +1,274 @@
import re
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
class ORMModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
# ---------- customers ----------
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):
pass
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):
id: int
created_at: datetime | None = None
# ---------- nas ----------
class NasBase(BaseModel):
nasname: str = Field(max_length=128)
shortname: str | None = Field(default=None, max_length=32)
type: str | None = Field(default="other", max_length=30)
ports: int | None = None
secret: str = Field(default="secret", max_length=60)
server: str | None = Field(default=None, max_length=64)
community: str | None = Field(default=None, max_length=50)
description: str | None = Field(default="RADIUS Client", max_length=200)
class NasCreate(NasBase):
pass
class NasUpdate(BaseModel):
nasname: str | None = Field(default=None, max_length=128)
shortname: str | None = Field(default=None, max_length=32)
type: str | None = Field(default=None, max_length=30)
ports: int | None = None
secret: str | None = Field(default=None, max_length=60)
server: str | None = Field(default=None, max_length=64)
community: str | None = Field(default=None, max_length=50)
description: str | None = Field(default=None, max_length=200)
class NasOut(ORMModel, NasBase):
id: int
# ---------- attribute pair tables (radcheck / radreply) ----------
class UserAttrBase(BaseModel):
username: str = Field(max_length=64)
attribute: str = Field(max_length=64)
op: str = Field(max_length=2)
value: str = Field(max_length=253)
class UserAttrCreate(UserAttrBase):
pass
class UserAttrUpdate(BaseModel):
username: str | None = Field(default=None, max_length=64)
attribute: str | None = Field(default=None, max_length=64)
op: str | None = Field(default=None, max_length=2)
value: str | None = Field(default=None, max_length=253)
class UserAttrOut(ORMModel, UserAttrBase):
id: int
# ---------- group attribute tables (radgroupcheck / radgroupreply / vlans) ----------
class GroupAttrBase(BaseModel):
groupname: str = Field(max_length=64)
attribute: str = Field(max_length=64)
op: str = Field(max_length=2)
value: str = Field(max_length=253)
class GroupAttrCreate(GroupAttrBase):
pass
class GroupAttrUpdate(BaseModel):
groupname: str | None = Field(default=None, max_length=64)
attribute: str | None = Field(default=None, max_length=64)
op: str | None = Field(default=None, max_length=2)
value: str | None = Field(default=None, max_length=253)
class GroupAttrOut(ORMModel, GroupAttrBase):
id: int
# ---------- vlans (logical view over radgroupreply) ----------
class VlanOut(BaseModel):
alias: str # radgroupreply.groupname
vlanid: int # value of the Tunnel-Private-Group-Id attribute
class VlanCreate(BaseModel):
vlanid: int = Field(ge=1, le=4094, description="802.1Q VLAN ID")
alias: str = Field(min_length=1, max_length=64, description="Group name for this VLAN")
class VlanEdit(BaseModel):
vlanid: int = Field(ge=1, le=4094, description="VLAN ID identifying the group to rename")
alias: str = Field(min_length=1, max_length=64, description="New alias (groupname)")
# ---------- devices (logical view over radcheck + radusergroup + customers) ----------
_MAC_RE = r"^[0-9A-Fa-f]{2}([-:][0-9A-Fa-f]{2}){5}$"
def _normalize_mac(mac: str) -> str:
"""Canonicalize a MAC to uppercase, hyphen-separated (AA-BB-CC-DD-EE-FF)."""
return mac.strip().upper().replace(":", "-")
_PHONE_ERROR = "Enter a valid phone number"
def _normalize_phone(raw: str) -> str:
"""Validate a Maldivian mobile number and return the 7-digit local form.
Rules: 7 digits, starts with 9 or 7. A 960/+960 country code is stripped ONLY
when the number is 10 digits (960 + 7) — a bare 7-digit number like 9601234 is
a valid local number and is never stripped.
"""
digits = re.sub(r"\D", "", raw or "")
if len(digits) == 10 and digits.startswith("960"):
digits = digits[3:]
if len(digits) == 7 and digits[0] in ("7", "9"):
return digits
raise ValueError(_PHONE_ERROR)
class DeviceOut(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)
class DeviceCreate(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)")
phone: str = Field(min_length=1, max_length=32, description="7-digit mobile (9/7…), 960/+960 optional")
alias: str | None = Field(default=None, max_length=64, description="Optional device alias (metadata)")
@field_validator("mac_address")
@classmethod
def _norm(cls, v: str) -> str:
return _normalize_mac(v)
@field_validator("phone")
@classmethod
def _norm_phone(cls, v: str) -> str:
return _normalize_phone(v)
class DeviceEdit(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
name: str | None = Field(default=None, max_length=128)
phone: str | None = Field(default=None, max_length=32)
alias: str | None = Field(default=None, max_length=64)
@field_validator("mac_address")
@classmethod
def _norm(cls, v: str) -> str:
return _normalize_mac(v)
@field_validator("phone")
@classmethod
def _norm_phone(cls, v: str | None) -> str | None:
return None if v is None else _normalize_phone(v)
@model_validator(mode="after")
def _at_least_one(self):
if all(v is None for v in (self.group, self.status, self.name, self.phone, self.alias)):
raise ValueError("provide at least one of: group, status, name, phone, alias")
return self
# ---------- radusergroup ----------
class UserGroupBase(BaseModel):
username: str = Field(max_length=64)
groupname: str = Field(max_length=64)
priority: int = 1
class UserGroupCreate(UserGroupBase):
pass
class UserGroupUpdate(BaseModel):
username: str | None = Field(default=None, max_length=64)
groupname: str | None = Field(default=None, max_length=64)
priority: int | None = None
class UserGroupOut(ORMModel, UserGroupBase):
id: int
# ---------- read-only: radacct ----------
class RadAcctOut(ORMModel):
radacctid: int
acctsessionid: str
acctuniqueid: str
username: str
realm: str | None = None
nasipaddress: str
nasportid: str | None = None
nasporttype: str | None = None
acctstarttime: datetime | None = None
acctupdatetime: datetime | None = None
acctstoptime: datetime | None = None
acctinterval: int | None = None
acctsessiontime: int | None = None
acctauthentic: str | None = None
connectinfo_start: str | None = None
connectinfo_stop: str | None = None
acctinputoctets: int | None = None
acctoutputoctets: int | None = None
calledstationid: str
callingstationid: str
acctterminatecause: str
servicetype: str | None = None
framedprotocol: str | None = None
framedipaddress: str
class_: str | None = Field(default=None, alias="class")
# ---------- read-only: radpostauth ----------
class RadPostAuthOut(ORMModel):
id: int
username: str
reply: str
authdate: datetime | None = None
class_: str | None = Field(default=None, alias="class")
# ---------- read-only: nasreload ----------
class NasReloadOut(ORMModel):
nasipaddress: str
reloadtime: datetime
+71
View File
@@ -0,0 +1,71 @@
adding a new device
example post /device/new
macaddress,group
removing an existing device
listing vlans
GET /vlan/
SELECT * FROM `radgroupreply`
this will return 3 duplicate "groupnames" with different attribute and vlaue
only need to return group name and value for Tunnel-Private-Group-Id
adding new vlan
need to add 3 items with different attribute and value,
for example to add staff vlan with vlan ID 55
api call should be like POST vlan/add and json data vlanid:55, alias staff
'INSERT INTO `radgroupreply` (`groupname`, `attribute`, `op`, `value`)
VALUES ('staff', 'Tunnel-Type', '=', 'VLAN');
INSERT INTO `radgroupreply` (`groupname`, `attribute`, `op`, `value`)
VALUES ('staff', 'Tunnel-Medium-Type', '=', 'IEEE-802');
INSERT INTO `radgroupreply` (`groupname`, `attribute`, `op`, `value`)
VALUES ('staff', 'Tunnel-Private-Group-Id', '=', '55');
'
deleting existing vlan
you get api call to delete vlan
DELETE vlan/55
and the sqlcommands should you looking for Tunnel-Private-Group-Id=55 and then deleting all the maching group names
edit existing vlan (alias)
post request, and allow to edit alias
disallow to add duplicate vlans or alias
listing existing devices
get /device/
SELECT * FROM `radusergroup`
and then
SELECT * FROM `customers`
return mac address, groupname and status
adding new device
this input feils are
'
POST /device/add with mac address and group and it involves few Commands
`
SELECT * FROM `radgroupreply`
# and see if the group in the device add command exists
# respond 4xx (whatever needed with reason group not found)
# else continue
INSERT INTO `radcheck` (`username`, `attribute`, `op`, `value`)
VALUES ('14-99-3E-74-CB-7F', 'Cleartext-Password', ':=', '14-99-3E-74-CB-7F');
INSERT INTO `radusergroup` (`username`, `groupname`, `priority`)
VALUES ('14-99-3E-74-CB-7F', 'staff', '1');
INSERT INTO `customers` (`username`, `mac_address`, `status`, `created_at`)
VALUES ('14-99-3E-74-CB-7F', '14-99-3E-74-CB-7F', 'paid', now());
`
deleting existing deivce
edit existing device (input macaddress, edit groupname, edit status. do allow to edit just 1 feild)
use sesnible commands based on add devices
i would also like a device alias, added to customers table like
"Customer name, device name, phone number"
these are to be ignored by radius server, but helpful for human review
and add input fields during adding device and also listing devices
+7
View File
@@ -0,0 +1,7 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
SQLAlchemy==2.0.36
PyMySQL==1.1.1
pydantic==2.10.4
pydantic-settings==2.7.1
python-dotenv==1.0.1