Complete Python/NiceGUI rewrite of the Wirezone (Elixir/Phoenix) VPN management platform. All 10 implementation phases delivered. Core stack: - NiceGUI reactive UI with SQLModel ORM on PostgreSQL (asyncpg) - Alembic migrations, Valkey/Redis cache, pydantic-settings config - WireGuard management via subprocess (wg/ip/nft CLIs) - 164 tests passing, 35% code coverage Features: - User/device/rule CRUD with admin and unprivileged roles - Full device config form with per-device WG overrides - WireGuard client config generation with QR codes - REST API (v0) with Bearer token auth for all resources - TOTP MFA with QR registration and challenge flow - OIDC SSO with authlib (provider registry, auto-create users) - Magic link passwordless sign-in via email - SAML SP-initiated SSO with IdP metadata parsing - WebAuthn/FIDO2 security key registration - nftables firewall with per-user chains and masquerade - Background tasks: WG stats polling, VPN session expiry, OIDC token refresh, WAN connectivity checks - Startup reconciliation (DB ↔ WireGuard state sync) - In-memory notification system with header badge - Admin UI: users, devices, rules, settings (3 tabs), diagnostics - Loguru logging with optional timestamped file output Deployment: - Multi-stage Dockerfile (python:3.13-slim) - Docker Compose prod stack (bridge networking, NET_ADMIN, nftables) - Forgejo CI: tests → semantic versioning → Docker registry push - Health endpoint at /api/health
119 lines
3.9 KiB
Python
119 lines
3.9 KiB
Python
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from loguru import logger
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlmodel import select
|
|
|
|
from wiregui.api.deps import get_current_api_user, get_db
|
|
from wiregui.config import get_settings
|
|
from wiregui.models.device import Device
|
|
from wiregui.models.user import User
|
|
from wiregui.schemas.device import DeviceCreate, DeviceRead, DeviceUpdate
|
|
from wiregui.services.events import on_device_created, on_device_deleted, on_device_updated
|
|
from wiregui.utils.crypto import generate_keypair, generate_preshared_key
|
|
from wiregui.utils.network import allocate_ipv4, allocate_ipv6
|
|
|
|
router = APIRouter(prefix="/devices", tags=["devices"])
|
|
|
|
|
|
@router.get("/", response_model=list[DeviceRead])
|
|
async def list_devices(
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_api_user),
|
|
):
|
|
if current_user.role == "admin":
|
|
result = await session.execute(select(Device).order_by(Device.inserted_at.desc()))
|
|
else:
|
|
result = await session.execute(
|
|
select(Device).where(Device.user_id == current_user.id).order_by(Device.inserted_at.desc())
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.get("/{device_id}", response_model=DeviceRead)
|
|
async def get_device(
|
|
device_id: UUID,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_api_user),
|
|
):
|
|
device = await session.get(Device, device_id)
|
|
if not device:
|
|
raise HTTPException(404, "Device not found")
|
|
if current_user.role != "admin" and device.user_id != current_user.id:
|
|
raise HTTPException(403, "Access denied")
|
|
return device
|
|
|
|
|
|
@router.post("/", response_model=DeviceRead, status_code=201)
|
|
async def create_device(
|
|
body: DeviceCreate,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_api_user),
|
|
):
|
|
settings = get_settings()
|
|
owner_id = body.user_id if (body.user_id and current_user.role == "admin") else current_user.id
|
|
|
|
_private_key, public_key = generate_keypair()
|
|
psk = generate_preshared_key()
|
|
ipv4 = await allocate_ipv4(session, settings.wg_ipv4_network)
|
|
ipv6 = await allocate_ipv6(session, settings.wg_ipv6_network)
|
|
|
|
device = Device(
|
|
name=body.name,
|
|
description=body.description,
|
|
public_key=public_key,
|
|
preshared_key=psk,
|
|
ipv4=ipv4,
|
|
ipv6=ipv6,
|
|
user_id=owner_id,
|
|
)
|
|
session.add(device)
|
|
await session.commit()
|
|
await session.refresh(device)
|
|
|
|
logger.info("API: device created {} ({})", device.name, device.ipv4)
|
|
await on_device_created(device)
|
|
return device
|
|
|
|
|
|
@router.put("/{device_id}", response_model=DeviceRead)
|
|
async def update_device(
|
|
device_id: UUID,
|
|
body: DeviceUpdate,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_api_user),
|
|
):
|
|
device = await session.get(Device, device_id)
|
|
if not device:
|
|
raise HTTPException(404, "Device not found")
|
|
if current_user.role != "admin" and device.user_id != current_user.id:
|
|
raise HTTPException(403, "Access denied")
|
|
|
|
for key, val in body.model_dump(exclude_unset=True).items():
|
|
setattr(device, key, val)
|
|
|
|
session.add(device)
|
|
await session.commit()
|
|
await session.refresh(device)
|
|
|
|
await on_device_updated(device)
|
|
return device
|
|
|
|
|
|
@router.delete("/{device_id}", status_code=204)
|
|
async def delete_device(
|
|
device_id: UUID,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_api_user),
|
|
):
|
|
device = await session.get(Device, device_id)
|
|
if not device:
|
|
raise HTTPException(404, "Device not found")
|
|
if current_user.role != "admin" and device.user_id != current_user.id:
|
|
raise HTTPException(403, "Access denied")
|
|
|
|
await session.delete(device)
|
|
await session.commit()
|
|
logger.info("API: device deleted {}", device.name)
|
|
await on_device_deleted(device)
|