commit eccda2b26d508119c2141fbd1cab4d2e70e57a23 Author: Shihaam Abdul Rahman Date: Fri Jul 31 21:04:41 2026 +0500 init, add/delete/edit vlans and devices diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..689bafc --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee35c31 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +venv/* +.env +tmp/ +.build/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca05bd6 --- /dev/null +++ b/README.md @@ -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: +``` + +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=`. + +| Action | Request | +|------------------|-----------------------------------------------------| +| List VLANs | `GET /vlan/` → `[{"alias","vlanid"}, ...]` | +| Add a VLAN | `POST /vlan/add` body `{"vlanid":55,"alias":"staff"}` (inserts the 3 rows) | +| Rename alias | `POST /vlan/edit` body `{"vlanid":55,"alias":"employees"}` | +| Delete a VLAN | `DELETE /vlan/{vlanid}` (removes all rows for that group) | + +- List returns **one row per VLAN** — only the alias and VLAN ID, not the raw + `Tunnel-*` attribute rows. +- Duplicate **VLAN ID** or **alias** on add → `409`. +- `vlanid` must be `1–4094`. +- Renaming updates `radgroupreply.groupname` only; if you also map users to groups + in `radusergroup`, update those separately. + +### 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 +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..0106f8d --- /dev/null +++ b/app/auth.py @@ -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") diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..6c66df8 --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/crud.py b/app/crud.py new file mode 100644 index 0000000..196299e --- /dev/null +++ b/app/crud.py @@ -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" diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..45fe02a --- /dev/null +++ b/app/database.py @@ -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() diff --git a/app/errors.py b/app/errors.py new file mode 100644 index 0000000..218c9a5 --- /dev/null +++ b/app/errors.py @@ -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}) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..2fdabeb --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..5b36f68 --- /dev/null +++ b/app/models.py @@ -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) diff --git a/app/pagination.py b/app/pagination.py new file mode 100644 index 0000000..b7c77c0 --- /dev/null +++ b/app/pagination.py @@ -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 diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/_attr_factory.py b/app/routers/_attr_factory.py new file mode 100644 index 0000000..c5cfb2b --- /dev/null +++ b/app/routers/_attr_factory.py @@ -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) diff --git a/app/routers/customers.py b/app/routers/customers.py new file mode 100644 index 0000000..ebb924b --- /dev/null +++ b/app/routers/customers.py @@ -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) diff --git a/app/routers/device.py b/app/routers/device.py new file mode 100644 index 0000000..51e25c5 --- /dev/null +++ b/app/routers/device.py @@ -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 = (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() diff --git a/app/routers/nas.py b/app/routers/nas.py new file mode 100644 index 0000000..0ab3cad --- /dev/null +++ b/app/routers/nas.py @@ -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) diff --git a/app/routers/nasreload.py b/app/routers/nasreload.py new file mode 100644 index 0000000..4390eee --- /dev/null +++ b/app/routers/nasreload.py @@ -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") diff --git a/app/routers/radacct.py b/app/routers/radacct.py new file mode 100644 index 0000000..899008b --- /dev/null +++ b/app/routers/radacct.py @@ -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") diff --git a/app/routers/radcheck.py b/app/routers/radcheck.py new file mode 100644 index 0000000..57d23da --- /dev/null +++ b/app/routers/radcheck.py @@ -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, +) diff --git a/app/routers/radgroupcheck.py b/app/routers/radgroupcheck.py new file mode 100644 index 0000000..55fac75 --- /dev/null +++ b/app/routers/radgroupcheck.py @@ -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, +) diff --git a/app/routers/radgroupreply.py b/app/routers/radgroupreply.py new file mode 100644 index 0000000..7963327 --- /dev/null +++ b/app/routers/radgroupreply.py @@ -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, +) diff --git a/app/routers/radpostauth.py b/app/routers/radpostauth.py new file mode 100644 index 0000000..87e13f4 --- /dev/null +++ b/app/routers/radpostauth.py @@ -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) diff --git a/app/routers/radreply.py b/app/routers/radreply.py new file mode 100644 index 0000000..3156afe --- /dev/null +++ b/app/routers/radreply.py @@ -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, +) diff --git a/app/routers/radusergroup.py b/app/routers/radusergroup.py new file mode 100644 index 0000000..bcbe38a --- /dev/null +++ b/app/routers/radusergroup.py @@ -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) diff --git a/app/routers/vlan.py b/app/routers/vlan.py new file mode 100644 index 0000000..33494e7 --- /dev/null +++ b/app/routers/vlan.py @@ -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() diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..898e15d --- /dev/null +++ b/app/schemas.py @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f20127e --- /dev/null +++ b/requirements.txt @@ -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