init, add/delete/edit vlans and devices

This commit is contained in:
2026-07-31 21:04:41 +05:00
commit eccda2b26d
28 changed files with 1487 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/
+226
View File
@@ -0,0 +1,226 @@
# 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.
## 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). MAC input is normalized to uppercase,
hyphen-separated (`AA-BB-CC-DD-EE-FF`); colons and lowercase are accepted.
| Action | Request |
|------------------|-----------------------------------------------------|
| List devices | `GET /device/``{total,limit,offset,items:[{mac_address,group,status}]}` |
| Get one device | `GET /device/{mac_address}` |
| Add a device | `POST /device/add` body `{"mac_address":"14-99-3E-74-CB-7F","group":"staff"}` |
| Edit a device | `POST /device/edit` body `{"mac_address":"...","group":"...","status":"..."}` |
| Delete a device | `DELETE /device/{mac_address}` (removes all 3 rows) |
- **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 `group` and/or `status` — supply either or both (at least one
required). 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.
- 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)
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"}' -s | jq
# Edit a device (any one field: groupname and/or status)
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"}' -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) |
| 401 | Missing/invalid `X-API-Key` |
| 404 | Row 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)
+133
View File
@@ -0,0 +1,133 @@
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")
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)
+114
View File
@@ -0,0 +1,114 @@
"""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).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) for mac, gn, st 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)
.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])
@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"))
db.commit()
return DeviceOut(mac_address=mac, group=payload.group, status="paid")
@router.post("/edit", response_model=DeviceOut)
def edit_device(payload: DeviceEdit, db: Session = Depends(get_db)):
"""Edit a device's group and/or status. Any single field may be supplied."""
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
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)
@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()
+230
View File
@@ -0,0 +1,230 @@
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"
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
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(":", "-")
class DeviceOut(BaseModel):
mac_address: str
group: str | None = None # radusergroup.groupname
status: str | None = None # customers.status
class DeviceCreate(BaseModel):
mac_address: str = Field(pattern=_MAC_RE, max_length=17)
group: str = Field(min_length=1, max_length=64)
@field_validator("mac_address")
@classmethod
def _norm(cls, v: str) -> str:
return _normalize_mac(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
@field_validator("mac_address")
@classmethod
def _norm(cls, v: str) -> str:
return _normalize_mac(v)
@model_validator(mode="after")
def _at_least_one(self):
if self.group is None and self.status is None:
raise ValueError("provide at least one of: group, status")
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
+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