first commit

This commit is contained in:
Stefan Hacker
2026-02-02 09:46:35 +01:00
commit 6901dc369b
98 changed files with 13030 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
"""Web routes for browser-based UI using Jinja2 + HTMX."""
from fastapi import APIRouter
from . import auth, dashboard, gateways, users, tenants, applications, connections, htmx
from . import ca, vpn_servers, vpn_profiles
# Create web router
web_router = APIRouter()
# Include web route modules
web_router.include_router(auth.router, tags=["Web Auth"])
web_router.include_router(dashboard.router, tags=["Web Dashboard"])
web_router.include_router(gateways.router, tags=["Web Gateways"])
web_router.include_router(users.router, tags=["Web Users"])
web_router.include_router(tenants.router, tags=["Web Tenants"])
web_router.include_router(applications.router, tags=["Web Applications"])
web_router.include_router(connections.router, tags=["Web Connections"])
web_router.include_router(htmx.router, prefix="/htmx", tags=["HTMX Partials"])
# PKI & VPN Management
web_router.include_router(ca.router, tags=["Web CA"])
web_router.include_router(vpn_servers.router, tags=["Web VPN Servers"])
web_router.include_router(vpn_profiles.router, tags=["Web VPN Profiles"])
+156
View File
@@ -0,0 +1,156 @@
"""Application template management web routes (Super Admin only)."""
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.user import User
from ..models.endpoint import ApplicationTemplate, Protocol
from .deps import require_super_admin_web, flash, get_flashed_messages
router = APIRouter()
@router.get("/applications", response_class=HTMLResponse)
async def list_applications(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""List all application templates."""
applications = db.query(ApplicationTemplate).order_by(ApplicationTemplate.name).all()
return request.app.state.templates.TemplateResponse(
"applications/list.html",
{
"request": request,
"current_user": current_user,
"applications": applications,
"flash_messages": get_flashed_messages(request)
}
)
@router.get("/applications/new", response_class=HTMLResponse)
async def new_application_form(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""New application template form."""
return request.app.state.templates.TemplateResponse(
"applications/form.html",
{
"request": request,
"current_user": current_user,
"application": None,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/applications/new")
async def create_application(
request: Request,
name: str = Form(...),
default_port: int = Form(...),
protocol: str = Form("tcp"),
description: str = Form(None),
icon: str = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""Create new application template."""
# Check if name exists
existing = db.query(ApplicationTemplate).filter(ApplicationTemplate.name == name).first()
if existing:
flash(request, "Anwendungsname bereits vergeben", "danger")
return RedirectResponse(url="/applications/new", status_code=303)
application = ApplicationTemplate(
name=name,
default_port=default_port,
protocol=Protocol(protocol),
description=description or None,
icon=icon or None
)
db.add(application)
db.commit()
flash(request, f"Anwendung '{name}' erstellt", "success")
return RedirectResponse(url="/applications", status_code=303)
@router.get("/applications/{app_id}/edit", response_class=HTMLResponse)
async def edit_application_form(
request: Request,
app_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""Edit application template form."""
application = db.query(ApplicationTemplate).filter(ApplicationTemplate.id == app_id).first()
if not application:
flash(request, "Anwendung nicht gefunden", "danger")
return RedirectResponse(url="/applications", status_code=303)
return request.app.state.templates.TemplateResponse(
"applications/form.html",
{
"request": request,
"current_user": current_user,
"application": application,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/applications/{app_id}/edit")
async def update_application(
request: Request,
app_id: int,
name: str = Form(...),
default_port: int = Form(...),
protocol: str = Form("tcp"),
description: str = Form(None),
icon: str = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""Update application template."""
application = db.query(ApplicationTemplate).filter(ApplicationTemplate.id == app_id).first()
if not application:
flash(request, "Anwendung nicht gefunden", "danger")
return RedirectResponse(url="/applications", status_code=303)
application.name = name
application.default_port = default_port
application.protocol = Protocol(protocol)
application.description = description or None
application.icon = icon or None
db.commit()
flash(request, "Anwendung aktualisiert", "success")
return RedirectResponse(url="/applications", status_code=303)
@router.post("/applications/{app_id}/delete")
async def delete_application(
request: Request,
app_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""Delete application template."""
application = db.query(ApplicationTemplate).filter(ApplicationTemplate.id == app_id).first()
if not application:
flash(request, "Anwendung nicht gefunden", "danger")
return RedirectResponse(url="/applications", status_code=303)
db.delete(application)
db.commit()
flash(request, f"Anwendung '{application.name}' gelöscht", "warning")
return RedirectResponse(url="/applications", status_code=303)
+58
View File
@@ -0,0 +1,58 @@
"""Web authentication routes (session-based)."""
from fastapi import APIRouter, Request, Form, Depends, HTTPException
from fastapi.responses import RedirectResponse, HTMLResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..services.auth_service import AuthService
from ..utils.security import verify_password
from .deps import get_flashed_messages
router = APIRouter()
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
"""Show login page."""
# If already logged in, redirect to dashboard
if request.session.get("user_id"):
return RedirectResponse(url="/dashboard", status_code=303)
return request.app.state.templates.TemplateResponse(
"auth/login.html",
{"request": request, "error": None, "flash_messages": get_flashed_messages(request)}
)
@router.post("/login", response_class=HTMLResponse)
async def login_submit(
request: Request,
username: str = Form(...),
password: str = Form(...),
remember: bool = Form(False),
db: Session = Depends(get_db)
):
"""Process login form."""
auth_service = AuthService(db)
user = auth_service.authenticate_user(username, password)
if not user:
return request.app.state.templates.TemplateResponse(
"auth/login.html",
{"request": request, "error": "Ungültige Anmeldedaten", "flash_messages": []}
)
# Store user in session
request.session["user_id"] = user.id
request.session["username"] = user.username
request.session["role"] = user.role.value
request.session["tenant_id"] = user.tenant_id
return RedirectResponse(url="/dashboard", status_code=303)
@router.get("/logout")
async def logout(request: Request):
"""Logout user."""
request.session.clear()
return RedirectResponse(url="/login", status_code=303)
+301
View File
@@ -0,0 +1,301 @@
"""Certificate Authority management web routes."""
from fastapi import APIRouter, Request, Depends, Form, UploadFile, File
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.orm import Session
from typing import Optional
from ..database import get_db
from ..models.user import User
from ..models.certificate_authority import CertificateAuthority, CAStatus
from ..services.certificate_service import CertificateService
from .deps import require_admin_web, flash, get_flashed_messages
router = APIRouter()
@router.get("/ca", response_class=HTMLResponse)
async def list_cas(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""List all Certificate Authorities."""
if current_user.is_super_admin:
cas = db.query(CertificateAuthority).all()
else:
cas = db.query(CertificateAuthority).filter(
(CertificateAuthority.tenant_id == current_user.tenant_id) |
(CertificateAuthority.tenant_id == None)
).all()
return request.app.state.templates.TemplateResponse(
"ca/list.html",
{
"request": request,
"current_user": current_user,
"cas": cas,
"flash_messages": get_flashed_messages(request)
}
)
@router.get("/ca/new", response_class=HTMLResponse)
async def new_ca_form(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""New CA form."""
return request.app.state.templates.TemplateResponse(
"ca/form.html",
{
"request": request,
"current_user": current_user,
"ca": None,
"mode": "create",
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/ca/new")
async def create_ca(
request: Request,
name: str = Form(...),
description: str = Form(None),
key_size: int = Form(4096),
validity_days: int = Form(3650),
organization: str = Form("mGuard VPN"),
country: str = Form("DE"),
state: str = Form("NRW"),
city: str = Form("Dortmund"),
is_default: bool = Form(False),
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Create new CA."""
try:
service = CertificateService(db)
tenant_id = None if current_user.is_super_admin else current_user.tenant_id
ca = service.create_ca(
name=name,
key_size=key_size,
validity_days=validity_days,
organization=organization,
country=country,
state=state,
city=city,
tenant_id=tenant_id,
created_by_id=current_user.id,
is_default=is_default
)
flash(request, f"CA '{name}' wird erstellt. DH-Parameter werden im Hintergrund generiert...", "success")
return RedirectResponse(url=f"/ca/{ca.id}", status_code=303)
except Exception as e:
flash(request, f"Fehler beim Erstellen der CA: {str(e)}", "danger")
return RedirectResponse(url="/ca/new", status_code=303)
@router.get("/ca/import", response_class=HTMLResponse)
async def import_ca_form(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Import CA form."""
return request.app.state.templates.TemplateResponse(
"ca/form.html",
{
"request": request,
"current_user": current_user,
"ca": None,
"mode": "import",
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/ca/import")
async def import_ca(
request: Request,
name: str = Form(...),
description: str = Form(None),
ca_cert: UploadFile = File(...),
ca_key: UploadFile = File(...),
dh_params: Optional[UploadFile] = File(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Import existing CA from files."""
try:
service = CertificateService(db)
ca_cert_pem = (await ca_cert.read()).decode('utf-8')
ca_key_pem = (await ca_key.read()).decode('utf-8')
dh_params_pem = (await dh_params.read()).decode('utf-8') if dh_params else None
tenant_id = None if current_user.is_super_admin else current_user.tenant_id
ca = service.import_ca(
name=name,
ca_cert_pem=ca_cert_pem,
ca_key_pem=ca_key_pem,
dh_params_pem=dh_params_pem,
tenant_id=tenant_id,
created_by_id=current_user.id
)
if dh_params_pem:
flash(request, f"CA '{name}' erfolgreich importiert", "success")
else:
flash(request, f"CA '{name}' importiert. DH-Parameter werden generiert...", "success")
return RedirectResponse(url=f"/ca/{ca.id}", status_code=303)
except Exception as e:
flash(request, f"Fehler beim Importieren: {str(e)}", "danger")
return RedirectResponse(url="/ca/import", status_code=303)
@router.get("/ca/{ca_id}", response_class=HTMLResponse)
async def ca_detail(
request: Request,
ca_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""CA detail page."""
ca = db.query(CertificateAuthority).filter(
CertificateAuthority.id == ca_id
).first()
if not ca:
flash(request, "CA nicht gefunden", "danger")
return RedirectResponse(url="/ca", status_code=303)
# Check access
if not current_user.is_super_admin and ca.tenant_id != current_user.tenant_id and ca.tenant_id is not None:
flash(request, "Zugriff verweigert", "danger")
return RedirectResponse(url="/ca", status_code=303)
return request.app.state.templates.TemplateResponse(
"ca/detail.html",
{
"request": request,
"current_user": current_user,
"ca": ca,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/ca/{ca_id}/set-default")
async def set_ca_default(
request: Request,
ca_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Set CA as default."""
ca = db.query(CertificateAuthority).filter(
CertificateAuthority.id == ca_id
).first()
if not ca:
flash(request, "CA nicht gefunden", "danger")
return RedirectResponse(url="/ca", status_code=303)
# Unset other defaults for same tenant
db.query(CertificateAuthority).filter(
CertificateAuthority.tenant_id == ca.tenant_id,
CertificateAuthority.id != ca.id
).update({"is_default": False})
ca.is_default = True
db.commit()
flash(request, f"'{ca.name}' ist jetzt die Standard-CA", "success")
return RedirectResponse(url=f"/ca/{ca_id}", status_code=303)
@router.get("/ca/{ca_id}/download/cert")
async def download_ca_cert(
ca_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Download CA certificate."""
ca = db.query(CertificateAuthority).filter(
CertificateAuthority.id == ca_id
).first()
if not ca or not ca.ca_cert:
return Response(status_code=404)
return Response(
content=ca.ca_cert,
media_type="application/x-pem-file",
headers={"Content-Disposition": f"attachment; filename={ca.name}-ca.crt"}
)
@router.get("/ca/{ca_id}/download/crl")
async def download_crl(
ca_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Download Certificate Revocation List."""
ca = db.query(CertificateAuthority).filter(
CertificateAuthority.id == ca_id
).first()
if not ca:
return Response(status_code=404)
service = CertificateService(db)
crl = service.get_crl(ca)
return Response(
content=crl,
media_type="application/x-pem-file",
headers={"Content-Disposition": f"attachment; filename={ca.name}-crl.pem"}
)
@router.post("/ca/{ca_id}/delete")
async def delete_ca(
request: Request,
ca_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Delete CA."""
ca = db.query(CertificateAuthority).filter(
CertificateAuthority.id == ca_id
).first()
if not ca:
flash(request, "CA nicht gefunden", "danger")
return RedirectResponse(url="/ca", status_code=303)
# Check if CA has servers or profiles
if ca.vpn_servers:
flash(request, "CA wird noch von VPN-Servern verwendet. Bitte zuerst löschen.", "danger")
return RedirectResponse(url=f"/ca/{ca_id}", status_code=303)
if ca.vpn_profiles:
flash(request, "CA wird noch von VPN-Profilen verwendet. Bitte zuerst löschen.", "danger")
return RedirectResponse(url=f"/ca/{ca_id}", status_code=303)
name = ca.name
db.delete(ca)
db.commit()
flash(request, f"CA '{name}' gelöscht", "warning")
return RedirectResponse(url="/ca", status_code=303)
+27
View File
@@ -0,0 +1,27 @@
"""Connection log web routes."""
from fastapi import APIRouter, Request, Depends
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.user import User
from .deps import get_current_user_web, get_flashed_messages
router = APIRouter()
@router.get("/connections", response_class=HTMLResponse)
async def connection_log(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Connection log page."""
return request.app.state.templates.TemplateResponse(
"connections/log.html",
{
"request": request,
"current_user": current_user,
"flash_messages": get_flashed_messages(request)
}
)
+57
View File
@@ -0,0 +1,57 @@
"""Dashboard web routes."""
from fastapi import APIRouter, Request, Depends
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from sqlalchemy import func
from ..database import get_db
from ..models.user import User
from ..models.gateway import Gateway
from ..models.endpoint import Endpoint
from ..models.access import ConnectionLog
from .deps import get_current_user_web, get_flashed_messages
router = APIRouter()
@router.get("/", response_class=HTMLResponse)
async def root(request: Request):
"""Root redirect to dashboard."""
if request.session.get("user_id"):
return RedirectResponse(url="/dashboard", status_code=303)
return RedirectResponse(url="/login", status_code=303)
@router.get("/dashboard", response_class=HTMLResponse)
async def dashboard(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Dashboard with overview."""
# Get statistics
gateways_total = db.query(Gateway).count()
gateways_online = db.query(Gateway).filter(Gateway.is_online == True).count()
endpoints_total = db.query(Endpoint).count()
users_total = db.query(User).filter(User.is_active == True).count()
active_connections = db.query(ConnectionLog).filter(
ConnectionLog.disconnected_at.is_(None)
).count()
stats = {
"gateways_total": gateways_total,
"gateways_online": gateways_online,
"endpoints_total": endpoints_total,
"users_total": users_total,
"active_connections": active_connections
}
return request.app.state.templates.TemplateResponse(
"dashboard/index.html",
{
"request": request,
"current_user": current_user,
"stats": stats,
"flash_messages": get_flashed_messages(request)
}
)
+61
View File
@@ -0,0 +1,61 @@
"""Web route dependencies."""
from fastapi import Request, HTTPException, Depends
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.user import User, UserRole
async def get_current_user_web(request: Request, db: Session = Depends(get_db)) -> User:
"""Get current user from session for web routes."""
user_id = request.session.get("user_id")
if not user_id:
raise HTTPException(status_code=303, headers={"Location": "/login"})
user = db.query(User).filter(User.id == user_id).first()
if not user or not user.is_active:
request.session.clear()
raise HTTPException(status_code=303, headers={"Location": "/login"})
return user
async def require_user_web(
current_user: User = Depends(get_current_user_web)
) -> User:
"""Require any authenticated user for web routes."""
return current_user
async def require_admin_web(
current_user: User = Depends(get_current_user_web)
) -> User:
"""Require admin role for web routes."""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich")
return current_user
async def require_super_admin_web(
current_user: User = Depends(get_current_user_web)
) -> User:
"""Require super admin role for web routes."""
if current_user.role != UserRole.SUPER_ADMIN:
raise HTTPException(status_code=403, detail="Super-Admin-Rechte erforderlich")
return current_user
def get_flashed_messages(request: Request) -> list[dict]:
"""Get and clear flash messages from session."""
messages = request.session.pop("flash_messages", [])
return messages
def flash(request: Request, message: str, category: str = "info"):
"""Add a flash message to session."""
if "flash_messages" not in request.session:
request.session["flash_messages"] = []
request.session["flash_messages"].append({"category": category, "message": message})
+250
View File
@@ -0,0 +1,250 @@
"""Gateway web routes."""
from fastapi import APIRouter, Request, Depends, Form, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.user import User, UserRole
from ..models.gateway import Gateway, RouterType, ProvisioningMethod
from ..models.endpoint import ApplicationTemplate
from ..models.tenant import Tenant
from ..models.access import UserGatewayAccess
from .deps import get_current_user_web, require_admin_web, flash, get_flashed_messages
router = APIRouter()
def get_accessible_gateways(db: Session, user: User):
"""Get gateways accessible by user."""
if user.role == UserRole.SUPER_ADMIN:
return db.query(Gateway).all()
elif user.role == UserRole.ADMIN:
return db.query(Gateway).filter(Gateway.tenant_id == user.tenant_id).all()
else:
return db.query(Gateway).join(
UserGatewayAccess,
UserGatewayAccess.gateway_id == Gateway.id
).filter(UserGatewayAccess.user_id == user.id).all()
@router.get("/gateways", response_class=HTMLResponse)
async def list_gateways(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""List gateways page."""
return request.app.state.templates.TemplateResponse(
"gateways/list.html",
{
"request": request,
"current_user": current_user,
"flash_messages": get_flashed_messages(request)
}
)
@router.get("/gateways/new", response_class=HTMLResponse)
async def new_gateway_form(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""New gateway form."""
tenants = db.query(Tenant).filter(Tenant.is_active == True).all()
return request.app.state.templates.TemplateResponse(
"gateways/form.html",
{
"request": request,
"current_user": current_user,
"gateway": None,
"tenants": tenants,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/gateways/new", response_class=HTMLResponse)
async def create_gateway(
request: Request,
name: str = Form(...),
router_type: str = Form(...),
firmware_version: str = Form(None),
serial_number: str = Form(None),
location: str = Form(None),
vpn_subnet: str = Form(None),
description: str = Form(None),
tenant_id: int = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Create new gateway."""
# Determine tenant
if current_user.role == UserRole.SUPER_ADMIN and tenant_id:
gateway_tenant_id = tenant_id
else:
gateway_tenant_id = current_user.tenant_id
# Generate VPN cert CN
vpn_cert_cn = f"gateway-{name.lower().replace(' ', '-')}"
gateway = Gateway(
name=name,
router_type=RouterType(router_type),
firmware_version=firmware_version or None,
serial_number=serial_number or None,
location=location or None,
vpn_subnet=vpn_subnet or None,
description=description or None,
tenant_id=gateway_tenant_id,
vpn_cert_cn=vpn_cert_cn
)
db.add(gateway)
db.commit()
db.refresh(gateway)
flash(request, f"Gateway '{name}' erfolgreich erstellt", "success")
return RedirectResponse(url=f"/gateways/{gateway.id}", status_code=303)
@router.get("/gateways/{gateway_id}", response_class=HTMLResponse)
async def gateway_detail(
request: Request,
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Gateway detail page."""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
flash(request, "Gateway nicht gefunden", "danger")
return RedirectResponse(url="/gateways", status_code=303)
# Check access
if current_user.role == UserRole.TECHNICIAN:
access = db.query(UserGatewayAccess).filter(
UserGatewayAccess.user_id == current_user.id,
UserGatewayAccess.gateway_id == gateway_id
).first()
if not access:
flash(request, "Kein Zugriff auf dieses Gateway", "danger")
return RedirectResponse(url="/gateways", status_code=303)
templates = db.query(ApplicationTemplate).all()
return request.app.state.templates.TemplateResponse(
"gateways/detail.html",
{
"request": request,
"current_user": current_user,
"gateway": gateway,
"templates": templates,
"flash_messages": get_flashed_messages(request)
}
)
@router.get("/gateways/{gateway_id}/edit", response_class=HTMLResponse)
async def edit_gateway_form(
request: Request,
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Edit gateway form."""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
flash(request, "Gateway nicht gefunden", "danger")
return RedirectResponse(url="/gateways", status_code=303)
tenants = db.query(Tenant).filter(Tenant.is_active == True).all()
return request.app.state.templates.TemplateResponse(
"gateways/form.html",
{
"request": request,
"current_user": current_user,
"gateway": gateway,
"tenants": tenants,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/gateways/{gateway_id}/edit")
async def update_gateway(
request: Request,
gateway_id: int,
name: str = Form(...),
router_type: str = Form(...),
firmware_version: str = Form(None),
serial_number: str = Form(None),
location: str = Form(None),
vpn_subnet: str = Form(None),
description: str = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Update gateway."""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
flash(request, "Gateway nicht gefunden", "danger")
return RedirectResponse(url="/gateways", status_code=303)
gateway.name = name
gateway.router_type = RouterType(router_type)
gateway.firmware_version = firmware_version or None
gateway.serial_number = serial_number or None
gateway.location = location or None
gateway.vpn_subnet = vpn_subnet or None
gateway.description = description or None
db.commit()
flash(request, "Gateway aktualisiert", "success")
return RedirectResponse(url=f"/gateways/{gateway_id}", status_code=303)
@router.post("/gateways/{gateway_id}/delete")
async def delete_gateway(
request: Request,
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Delete gateway."""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
flash(request, "Gateway nicht gefunden", "danger")
return RedirectResponse(url="/gateways", status_code=303)
db.delete(gateway)
db.commit()
flash(request, f"Gateway '{gateway.name}' gelöscht", "warning")
return RedirectResponse(url="/gateways", status_code=303)
@router.get("/gateways/{gateway_id}/provision")
async def download_provisioning(
request: Request,
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Redirect to VPN profiles for provisioning.
Provisioning is now done through VPN profiles.
Each profile contains its own certificate and VPN server configuration.
"""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
flash(request, "Gateway nicht gefunden", "danger")
return RedirectResponse(url="/gateways", status_code=303)
# Redirect to profiles page
flash(request, "Bitte erstellen Sie ein VPN-Profil für das Provisioning", "info")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)
+698
View File
@@ -0,0 +1,698 @@
"""HTMX partial routes for dynamic updates."""
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.user import User, UserRole
from ..models.gateway import Gateway
from ..models.endpoint import Endpoint
from ..models.access import UserGatewayAccess, ConnectionLog
from ..models.vpn_server import VPNServer
from ..models.vpn_connection_log import VPNConnectionLog
from ..services.vpn_server_service import VPNServerService
from ..services.vpn_sync_service import VPNSyncService
from .deps import get_current_user_web
router = APIRouter()
@router.get("/dashboard/stats", response_class=HTMLResponse)
async def dashboard_stats(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Dashboard statistics partial."""
gateways_total = db.query(Gateway).count()
gateways_online = db.query(Gateway).filter(Gateway.is_online == True).count()
endpoints_total = db.query(Endpoint).count()
users_total = db.query(User).filter(User.is_active == True).count()
active_connections = db.query(ConnectionLog).filter(
ConnectionLog.disconnected_at.is_(None)
).count()
# Count VPN clients across all active servers
vpn_clients_total = 0
vpn_servers = db.query(VPNServer).filter(VPNServer.is_active == True).all()
service = VPNServerService(db)
for server in vpn_servers:
try:
clients = service.get_connected_clients(server)
vpn_clients_total += len(clients)
except:
pass # Server might be offline
return f"""
<div class="col-md-2 mb-3">
<div class="card stat-card bg-primary text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-subtitle mb-2 text-white-50">Gateways</h6>
<div class="stat-value">{gateways_online} / {gateways_total}</div>
</div>
<i class="bi bi-router stat-icon"></i>
</div>
</div>
</div>
</div>
<div class="col-md-2 mb-3">
<div class="card stat-card bg-success text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-subtitle mb-2 text-white-50">VPN-Clients</h6>
<div class="stat-value">{vpn_clients_total}</div>
</div>
<i class="bi bi-shield-check stat-icon"></i>
</div>
</div>
</div>
</div>
<div class="col-md-2 mb-3">
<div class="card stat-card bg-info text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-subtitle mb-2 text-white-50">Sessions</h6>
<div class="stat-value">{active_connections}</div>
</div>
<i class="bi bi-plug stat-icon"></i>
</div>
</div>
</div>
</div>
<div class="col-md-2 mb-3">
<div class="card stat-card bg-secondary text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-subtitle mb-2 text-white-50">Endpunkte</h6>
<div class="stat-value">{endpoints_total}</div>
</div>
<i class="bi bi-hdd-network stat-icon"></i>
</div>
</div>
</div>
</div>
<div class="col-md-2 mb-3">
<div class="card stat-card bg-warning text-dark">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-subtitle mb-2">Benutzer</h6>
<div class="stat-value">{users_total}</div>
</div>
<i class="bi bi-people stat-icon"></i>
</div>
</div>
</div>
</div>
"""
@router.get("/gateways/list", response_class=HTMLResponse)
@router.get("/gateways/search", response_class=HTMLResponse)
@router.get("/gateways/filter", response_class=HTMLResponse)
async def gateway_list_partial(
request: Request,
q: str = "",
status: str = "",
type: str = "",
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Gateway list partial for HTMX."""
query = db.query(Gateway)
# Filter by tenant for non-super-admins
if current_user.role != UserRole.SUPER_ADMIN:
if current_user.role == UserRole.ADMIN:
query = query.filter(Gateway.tenant_id == current_user.tenant_id)
else:
query = query.join(
UserGatewayAccess,
UserGatewayAccess.gateway_id == Gateway.id
).filter(UserGatewayAccess.user_id == current_user.id)
# Apply filters
if q:
query = query.filter(Gateway.name.ilike(f"%{q}%"))
if status == "online":
query = query.filter(Gateway.is_online == True)
elif status == "offline":
query = query.filter(Gateway.is_online == False)
if type:
query = query.filter(Gateway.router_type == type)
gateways = query.all()
if not gateways:
return """
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> Keine Gateways gefunden
</div>
"""
html = '<div class="row">'
for gw in gateways:
status_class = "online" if gw.is_online else "offline"
status_badge = '<span class="badge bg-success">Online</span>' if gw.is_online else '<span class="badge bg-secondary">Offline</span>'
last_seen = gw.last_seen.strftime('%d.%m.%Y %H:%M') if gw.last_seen else 'Nie'
html += f"""
<div class="col-md-6 col-lg-4 mb-3">
<div class="card gateway-card {status_class}">
<div class="card-body">
<h5 class="card-title">
<span class="status-indicator {status_class}"></span>
{gw.name}
</h5>
<p class="card-text text-muted small mb-2">
{gw.router_type} | {gw.location or 'Kein Standort'}
</p>
<p class="card-text small">
{status_badge}
<span class="text-muted ms-2">Zuletzt: {last_seen}</span>
</p>
<a href="/gateways/{gw.id}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> Details
</a>
</div>
</div>
</div>
"""
html += '</div>'
return html
@router.get("/gateways/status-list", response_class=HTMLResponse)
async def gateway_status_list(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Gateway status list for dashboard."""
query = db.query(Gateway)
if current_user.role != UserRole.SUPER_ADMIN:
if current_user.role == UserRole.ADMIN:
query = query.filter(Gateway.tenant_id == current_user.tenant_id)
gateways = query.limit(10).all()
if not gateways:
return '<p class="text-muted">Keine Gateways vorhanden</p>'
html = '<table class="table table-sm table-hover mb-0"><tbody>'
for gw in gateways:
status = '<span class="status-indicator online"></span>' if gw.is_online else '<span class="status-indicator offline"></span>'
html += f"""
<tr onclick="window.location='/gateways/{gw.id}'" style="cursor:pointer">
<td>{status} {gw.name}</td>
<td class="text-muted">{gw.router_type}</td>
<td class="text-end">
<a href="/gateways/{gw.id}" class="btn btn-sm btn-link">
<i class="bi bi-arrow-right"></i>
</a>
</td>
</tr>
"""
html += '</tbody></table>'
return html
@router.get("/gateways/{gateway_id}/endpoints", response_class=HTMLResponse)
async def gateway_endpoints_partial(
request: Request,
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Endpoints list partial."""
endpoints = db.query(Endpoint).filter(Endpoint.gateway_id == gateway_id).all()
if not endpoints:
return """
<div class="text-center text-muted py-4">
<i class="bi bi-inbox" style="font-size: 2rem;"></i>
<p>Keine Endpunkte definiert</p>
</div>
"""
html = '<table class="table table-hover mb-0"><thead><tr><th>Name</th><th>Adresse</th><th>Protokoll</th><th>Anwendung</th><th></th></tr></thead><tbody>'
for ep in endpoints:
protocol_badge = f'<span class="badge badge-{ep.protocol.value}">{ep.protocol.value.upper()}</span>'
html += f"""
<tr>
<td><strong>{ep.name}</strong></td>
<td><code>{ep.internal_ip}:{ep.port}</code></td>
<td>{protocol_badge}</td>
<td>{ep.application_name or '-'}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger"
hx-delete="/htmx/endpoints/{ep.id}"
hx-confirm="Endpunkt '{ep.name}' löschen?"
hx-target="#endpoints-list"
hx-swap="innerHTML">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
"""
html += '</tbody></table>'
return html
@router.post("/gateways/{gateway_id}/endpoints", response_class=HTMLResponse)
async def create_endpoint_htmx(
request: Request,
gateway_id: int,
name: str = Form(...),
internal_ip: str = Form(...),
port: int = Form(...),
protocol: str = Form("tcp"),
application_template_id: int = Form(None),
description: str = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Create endpoint via HTMX."""
from ..models.endpoint import Protocol
endpoint = Endpoint(
gateway_id=gateway_id,
name=name,
internal_ip=internal_ip,
port=port,
protocol=Protocol(protocol),
application_template_id=application_template_id if application_template_id else None,
description=description
)
db.add(endpoint)
db.commit()
# Return updated list
return await gateway_endpoints_partial(request, gateway_id, db, current_user)
@router.delete("/endpoints/{endpoint_id}", response_class=HTMLResponse)
async def delete_endpoint_htmx(
request: Request,
endpoint_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Delete endpoint via HTMX."""
endpoint = db.query(Endpoint).filter(Endpoint.id == endpoint_id).first()
if endpoint:
gateway_id = endpoint.gateway_id
db.delete(endpoint)
db.commit()
return await gateway_endpoints_partial(request, gateway_id, db, current_user)
return ""
@router.get("/gateways/{gateway_id}/access", response_class=HTMLResponse)
async def gateway_access_partial(
request: Request,
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""User access list partial for gateway."""
access_list = db.query(UserGatewayAccess).filter(
UserGatewayAccess.gateway_id == gateway_id
).all()
if not access_list:
return """
<div class="text-center text-muted py-4">
<i class="bi bi-people" style="font-size: 2rem;"></i>
<p>Keine Benutzer haben Zugriff</p>
</div>
"""
html = '<table class="table table-hover mb-0"><thead><tr><th>Benutzer</th><th>Rolle</th><th>Gewährt am</th><th>Gewährt von</th><th></th></tr></thead><tbody>'
for access in access_list:
user = db.query(User).filter(User.id == access.user_id).first()
granted_by = db.query(User).filter(User.id == access.granted_by_id).first() if access.granted_by_id else None
granted_at = access.granted_at.strftime('%d.%m.%Y') if access.granted_at else '-'
html += f"""
<tr>
<td><strong>{user.username if user else '-'}</strong></td>
<td><span class="badge bg-secondary">{user.role.value if user else '-'}</span></td>
<td>{granted_at}</td>
<td>{granted_by.username if granted_by else '-'}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger"
hx-delete="/htmx/gateways/{gateway_id}/access/{access.user_id}"
hx-confirm="Zugriff entziehen?"
hx-target="#access-list"
hx-swap="innerHTML">
<i class="bi bi-x-lg"></i>
</button>
</td>
</tr>
"""
html += '</tbody></table>'
return html
@router.post("/gateways/{gateway_id}/access", response_class=HTMLResponse)
async def grant_access_htmx(
request: Request,
gateway_id: int,
user_id: int = Form(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Grant user access to gateway via HTMX."""
# Check if access already exists
existing = db.query(UserGatewayAccess).filter(
UserGatewayAccess.gateway_id == gateway_id,
UserGatewayAccess.user_id == user_id
).first()
if not existing:
access = UserGatewayAccess(
gateway_id=gateway_id,
user_id=user_id,
granted_by_id=current_user.id
)
db.add(access)
db.commit()
return await gateway_access_partial(request, gateway_id, db, current_user)
@router.delete("/gateways/{gateway_id}/access/{user_id}", response_class=HTMLResponse)
async def revoke_access_htmx(
request: Request,
gateway_id: int,
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Revoke user access to gateway via HTMX."""
access = db.query(UserGatewayAccess).filter(
UserGatewayAccess.gateway_id == gateway_id,
UserGatewayAccess.user_id == user_id
).first()
if access:
db.delete(access)
db.commit()
return await gateway_access_partial(request, gateway_id, db, current_user)
@router.get("/gateways/{gateway_id}/vpn-log", response_class=HTMLResponse)
async def gateway_vpn_log_partial(
request: Request,
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""VPN connection log for gateway."""
sync_service = VPNSyncService(db)
logs = sync_service.get_gateway_connection_logs(gateway_id, limit=20)
if not logs:
return """
<div class="text-center text-muted py-4">
<i class="bi bi-shield-x" style="font-size: 2rem;"></i>
<p>Keine VPN-Verbindungen aufgezeichnet</p>
</div>
"""
html = '''<table class="table table-hover table-sm mb-0">
<thead>
<tr>
<th>Profil</th>
<th>Server</th>
<th>Echte Adresse</th>
<th>Verbunden</th>
<th>Getrennt</th>
<th>Dauer</th>
<th>Traffic</th>
</tr>
</thead>
<tbody>'''
for log in logs:
profile_name = log.vpn_profile.name if log.vpn_profile else '-'
server_name = log.vpn_server.name if log.vpn_server else '-'
real_addr = log.real_address or '-'
connected = log.connected_at.strftime('%d.%m. %H:%M') if log.connected_at else '-'
if log.disconnected_at:
disconnected = log.disconnected_at.strftime('%H:%M')
duration = log.duration_seconds or 0
if duration >= 3600:
duration_str = f"{duration // 3600}h {(duration % 3600) // 60}m"
elif duration >= 60:
duration_str = f"{duration // 60}m"
else:
duration_str = f"{duration}s"
status_badge = ''
else:
disconnected = '<span class="badge bg-success">Aktiv</span>'
duration_str = '-'
status_badge = ''
rx = log.bytes_received or 0
tx = log.bytes_sent or 0
rx_str = f"{rx / 1024 / 1024:.1f}" if rx > 1024*1024 else f"{rx / 1024:.0f}K"
tx_str = f"{tx / 1024 / 1024:.1f}" if tx > 1024*1024 else f"{tx / 1024:.0f}K"
traffic = f"{rx_str}{tx_str}"
html += f'''
<tr>
<td>{profile_name}</td>
<td>{server_name}</td>
<td><code class="small">{real_addr}</code></td>
<td>{connected}</td>
<td>{disconnected}</td>
<td>{duration_str}</td>
<td class="small">{traffic}</td>
</tr>'''
html += '</tbody></table>'
return html
@router.get("/connections/count", response_class=HTMLResponse)
async def connections_count(
db: Session = Depends(get_db)
):
"""Active connections count."""
count = db.query(ConnectionLog).filter(
ConnectionLog.disconnected_at.is_(None)
).count()
# Also count VPN clients
vpn_clients = 0
vpn_servers = db.query(VPNServer).filter(VPNServer.is_active == True).all()
service = VPNServerService(db)
for server in vpn_servers:
try:
clients = service.get_connected_clients(server)
vpn_clients += len(clients)
except:
pass
return f'<i class="bi bi-shield-check"></i> {vpn_clients} VPN-Clients &nbsp; <i class="bi bi-plug"></i> {count} Sessions'
@router.get("/connections/vpn-clients", response_class=HTMLResponse)
async def vpn_clients_list(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""List of connected VPN clients (gateways)."""
# Sync connections with database (updates gateway status, creates logs)
sync_service = VPNSyncService(db)
sync_service.sync_all_connections()
# Get active connections from database
active_connections = sync_service.get_active_connections()
if not active_connections:
return '<p class="text-muted text-center py-3">Keine VPN-Clients verbunden</p>'
html = '''<table class="table table-hover table-sm">
<thead>
<tr>
<th>Gateway</th>
<th>Profil</th>
<th>VPN-Server</th>
<th>Echte Adresse</th>
<th>Empfangen</th>
<th>Gesendet</th>
<th>Verbunden seit</th>
</tr>
</thead>
<tbody>'''
for conn in active_connections:
gateway_name = conn.gateway.name if conn.gateway else '-'
gateway_id = conn.gateway.id if conn.gateway else ''
profile_name = conn.vpn_profile.name if conn.vpn_profile else '-'
server_name = conn.vpn_server.name if conn.vpn_server else '-'
server_id = conn.vpn_server.id if conn.vpn_server else ''
real_addr = conn.real_address or '-'
rx = conn.bytes_received or 0
tx = conn.bytes_sent or 0
connected = conn.connected_at.strftime('%d.%m.%Y %H:%M') if conn.connected_at else '-'
# Format bytes
rx_str = f"{rx / 1024 / 1024:.2f} MB" if rx > 1024*1024 else f"{rx / 1024:.1f} KB"
tx_str = f"{tx / 1024 / 1024:.2f} MB" if tx > 1024*1024 else f"{tx / 1024:.1f} KB"
html += f'''
<tr>
<td><a href="/gateways/{gateway_id}"><strong>{gateway_name}</strong></a></td>
<td>{profile_name}</td>
<td><a href="/vpn-servers/{server_id}">{server_name}</a></td>
<td><code>{real_addr}</code></td>
<td>{rx_str}</td>
<td>{tx_str}</td>
<td>{connected}</td>
</tr>'''
html += '</tbody></table>'
return html
@router.get("/connections/active", response_class=HTMLResponse)
async def active_connections(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Active connections partial."""
connections = db.query(ConnectionLog).filter(
ConnectionLog.disconnected_at.is_(None)
).all()
if not connections:
return '<p class="text-muted text-center py-3">Keine aktiven Verbindungen</p>'
html = '<ul class="list-group list-group-flush">'
for conn in connections:
user = db.query(User).filter(User.id == conn.user_id).first()
gateway = db.query(Gateway).filter(Gateway.id == conn.gateway_id).first()
endpoint = db.query(Endpoint).filter(Endpoint.id == conn.endpoint_id).first() if conn.endpoint_id else None
html += f"""
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>{user.username if user else 'Unknown'}</strong>
<i class="bi bi-arrow-right mx-2"></i>
{gateway.name if gateway else 'Unknown'}
{f' / {endpoint.name}' if endpoint else ''}
</div>
<span class="badge bg-success"><i class="bi bi-broadcast"></i> Verbunden</span>
</li>
"""
html += '</ul>'
return html
@router.get("/connections/recent", response_class=HTMLResponse)
async def recent_connections(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Recent connections for dashboard."""
connections = db.query(ConnectionLog).order_by(
ConnectionLog.connected_at.desc()
).limit(5).all()
if not connections:
return '<p class="text-muted text-center">Keine Verbindungen</p>'
html = '<ul class="list-group list-group-flush">'
for conn in connections:
user = db.query(User).filter(User.id == conn.user_id).first()
gateway = db.query(Gateway).filter(Gateway.id == conn.gateway_id).first()
time = conn.connected_at.strftime('%H:%M')
if conn.disconnected_at:
status = '<span class="text-muted"><i class="bi bi-x-circle"></i></span>'
else:
status = '<span class="text-success"><i class="bi bi-check-circle"></i></span>'
html += f"""
<li class="list-group-item d-flex justify-content-between align-items-center py-2">
<span>
{status}
<span class="ms-2">{user.username if user else '?'}</span>
<i class="bi bi-arrow-right text-muted mx-1"></i>
<span class="text-muted">{gateway.name if gateway else '?'}</span>
</span>
<small class="text-muted">{time}</small>
</li>
"""
html += '</ul>'
return html
@router.get("/connections/list", response_class=HTMLResponse)
async def connections_list(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Connection history list."""
connections = db.query(ConnectionLog).order_by(
ConnectionLog.connected_at.desc()
).limit(50).all()
if not connections:
return '<p class="text-muted text-center py-4">Keine Verbindungshistorie</p>'
html = '<table class="table table-hover"><thead><tr><th>Benutzer</th><th>Gateway</th><th>Endpunkt</th><th>Verbunden</th><th>Getrennt</th><th>Dauer</th></tr></thead><tbody>'
for conn in connections:
user = db.query(User).filter(User.id == conn.user_id).first()
gateway = db.query(Gateway).filter(Gateway.id == conn.gateway_id).first()
endpoint = db.query(Endpoint).filter(Endpoint.id == conn.endpoint_id).first() if conn.endpoint_id else None
connected = conn.connected_at.strftime('%d.%m.%Y %H:%M')
disconnected = conn.disconnected_at.strftime('%H:%M') if conn.disconnected_at else '<span class="badge bg-success">Aktiv</span>'
duration = ""
if conn.duration_seconds:
mins = conn.duration_seconds // 60
duration = f"{mins} Min."
html += f"""
<tr>
<td>{user.username if user else '-'}</td>
<td>{gateway.name if gateway else '-'}</td>
<td>{endpoint.name if endpoint else '-'}</td>
<td>{connected}</td>
<td>{disconnected}</td>
<td>{duration}</td>
</tr>
"""
html += '</tbody></table>'
return html
+151
View File
@@ -0,0 +1,151 @@
"""Tenant management web routes (Super Admin only)."""
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.user import User
from ..models.tenant import Tenant
from .deps import require_super_admin_web, flash, get_flashed_messages
router = APIRouter()
@router.get("/tenants", response_class=HTMLResponse)
async def list_tenants(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""List all tenants."""
tenants = db.query(Tenant).all()
return request.app.state.templates.TemplateResponse(
"tenants/list.html",
{
"request": request,
"current_user": current_user,
"tenants": tenants,
"flash_messages": get_flashed_messages(request)
}
)
@router.get("/tenants/new", response_class=HTMLResponse)
async def new_tenant_form(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""New tenant form."""
return request.app.state.templates.TemplateResponse(
"tenants/form.html",
{
"request": request,
"current_user": current_user,
"tenant": None,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/tenants/new")
async def create_tenant(
request: Request,
name: str = Form(...),
description: str = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""Create new tenant."""
# Check if name exists
existing = db.query(Tenant).filter(Tenant.name == name).first()
if existing:
flash(request, "Mandantenname bereits vergeben", "danger")
return RedirectResponse(url="/tenants/new", status_code=303)
tenant = Tenant(
name=name,
description=description or None
)
db.add(tenant)
db.commit()
flash(request, f"Mandant '{name}' erstellt", "success")
return RedirectResponse(url="/tenants", status_code=303)
@router.get("/tenants/{tenant_id}/edit", response_class=HTMLResponse)
async def edit_tenant_form(
request: Request,
tenant_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""Edit tenant form."""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
flash(request, "Mandant nicht gefunden", "danger")
return RedirectResponse(url="/tenants", status_code=303)
return request.app.state.templates.TemplateResponse(
"tenants/form.html",
{
"request": request,
"current_user": current_user,
"tenant": tenant,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/tenants/{tenant_id}/edit")
async def update_tenant(
request: Request,
tenant_id: int,
name: str = Form(...),
description: str = Form(None),
is_active: bool = Form(True),
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""Update tenant."""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
flash(request, "Mandant nicht gefunden", "danger")
return RedirectResponse(url="/tenants", status_code=303)
tenant.name = name
tenant.description = description or None
tenant.is_active = is_active
db.commit()
flash(request, "Mandant aktualisiert", "success")
return RedirectResponse(url="/tenants", status_code=303)
@router.post("/tenants/{tenant_id}/delete")
async def delete_tenant(
request: Request,
tenant_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_super_admin_web)
):
"""Delete tenant."""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
flash(request, "Mandant nicht gefunden", "danger")
return RedirectResponse(url="/tenants", status_code=303)
# Check if tenant has users or gateways
if tenant.users or tenant.gateways:
flash(request, "Mandant hat noch Benutzer oder Gateways. Bitte zuerst löschen.", "danger")
return RedirectResponse(url="/tenants", status_code=303)
db.delete(tenant)
db.commit()
flash(request, f"Mandant '{tenant.name}' gelöscht", "warning")
return RedirectResponse(url="/tenants", status_code=303)
+144
View File
@@ -0,0 +1,144 @@
"""User management web routes."""
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.user import User, UserRole
from ..utils.security import get_password_hash
from .deps import get_current_user_web, require_admin_web, flash, get_flashed_messages
router = APIRouter()
@router.get("/users", response_class=HTMLResponse)
async def list_users(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""List users."""
if current_user.role == UserRole.SUPER_ADMIN:
users = db.query(User).all()
else:
users = db.query(User).filter(User.tenant_id == current_user.tenant_id).all()
return request.app.state.templates.TemplateResponse(
"users/list.html",
{
"request": request,
"current_user": current_user,
"users": users,
"flash_messages": get_flashed_messages(request)
}
)
@router.get("/users/new", response_class=HTMLResponse)
async def new_user_form(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""New user form."""
from ..models.tenant import Tenant
tenants = db.query(Tenant).filter(Tenant.is_active == True).all()
return request.app.state.templates.TemplateResponse(
"users/form.html",
{
"request": request,
"current_user": current_user,
"user": None,
"tenants": tenants,
"roles": [r.value for r in UserRole if r != UserRole.SUPER_ADMIN or current_user.role == UserRole.SUPER_ADMIN],
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/users/new")
async def create_user(
request: Request,
username: str = Form(...),
email: str = Form(...),
password: str = Form(...),
role: str = Form(...),
full_name: str = Form(None),
tenant_id: int = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Create new user."""
# Check if username exists
existing = db.query(User).filter(User.username == username).first()
if existing:
flash(request, "Benutzername bereits vergeben", "danger")
return RedirectResponse(url="/users/new", status_code=303)
# Check if email exists
existing = db.query(User).filter(User.email == email).first()
if existing:
flash(request, "E-Mail bereits vergeben", "danger")
return RedirectResponse(url="/users/new", status_code=303)
# Determine tenant
if current_user.role == UserRole.SUPER_ADMIN and tenant_id:
user_tenant_id = tenant_id
else:
user_tenant_id = current_user.tenant_id
user = User(
username=username,
email=email,
password_hash=get_password_hash(password),
role=UserRole(role),
full_name=full_name or None,
tenant_id=user_tenant_id if UserRole(role) != UserRole.SUPER_ADMIN else None
)
db.add(user)
db.commit()
flash(request, f"Benutzer '{username}' erstellt", "success")
return RedirectResponse(url="/users", status_code=303)
@router.get("/users/{user_id}/access", response_class=HTMLResponse)
async def user_access(
request: Request,
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Manage user gateway access."""
from ..models.gateway import Gateway
from ..models.access import UserGatewayAccess
user = db.query(User).filter(User.id == user_id).first()
if not user:
flash(request, "Benutzer nicht gefunden", "danger")
return RedirectResponse(url="/users", status_code=303)
# Get all gateways and current access
if current_user.role == UserRole.SUPER_ADMIN:
gateways = db.query(Gateway).all()
else:
gateways = db.query(Gateway).filter(Gateway.tenant_id == current_user.tenant_id).all()
user_access = db.query(UserGatewayAccess).filter(
UserGatewayAccess.user_id == user_id
).all()
access_gateway_ids = [a.gateway_id for a in user_access]
return request.app.state.templates.TemplateResponse(
"users/access.html",
{
"request": request,
"current_user": current_user,
"user": user,
"gateways": gateways,
"access_gateway_ids": access_gateway_ids,
"flash_messages": get_flashed_messages(request)
}
)
+394
View File
@@ -0,0 +1,394 @@
"""VPN Profile management web routes (nested under gateways)."""
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.user import User
from ..models.gateway import Gateway
from ..models.vpn_server import VPNServer
from ..models.vpn_profile import VPNProfile
from ..services.vpn_profile_service import VPNProfileService
from .deps import require_admin_web, require_user_web, flash, get_flashed_messages
router = APIRouter()
@router.get("/gateways/{gateway_id}/profiles", response_class=HTMLResponse)
async def list_profiles(
request: Request,
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_user_web)
):
"""List VPN profiles for a gateway."""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
flash(request, "Gateway nicht gefunden", "danger")
return RedirectResponse(url="/gateways", status_code=303)
# Check access
if not current_user.is_admin and not any(
access.gateway_id == gateway_id for access in current_user.gateway_access
):
flash(request, "Zugriff verweigert", "danger")
return RedirectResponse(url="/gateways", status_code=303)
service = VPNProfileService(db)
profiles = service.get_profiles_for_gateway(gateway_id)
return request.app.state.templates.TemplateResponse(
"gateways/profiles.html",
{
"request": request,
"current_user": current_user,
"gateway": gateway,
"profiles": profiles,
"flash_messages": get_flashed_messages(request)
}
)
@router.get("/gateways/{gateway_id}/profiles/new", response_class=HTMLResponse)
async def new_profile_form(
request: Request,
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""New VPN profile form."""
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
flash(request, "Gateway nicht gefunden", "danger")
return RedirectResponse(url="/gateways", status_code=303)
# Get available VPN servers
if current_user.is_super_admin:
servers = db.query(VPNServer).filter(VPNServer.is_active == True).all()
else:
servers = db.query(VPNServer).filter(
(VPNServer.tenant_id == current_user.tenant_id) |
(VPNServer.tenant_id == None),
VPNServer.is_active == True
).all()
# Calculate next priority
existing_profiles = db.query(VPNProfile).filter(
VPNProfile.gateway_id == gateway_id
).count()
next_priority = existing_profiles + 1
return request.app.state.templates.TemplateResponse(
"gateways/profile_form.html",
{
"request": request,
"current_user": current_user,
"gateway": gateway,
"profile": None,
"vpn_servers": servers,
"next_priority": next_priority,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/gateways/{gateway_id}/profiles/new")
async def create_profile(
request: Request,
gateway_id: int,
name: str = Form(...),
vpn_server_id: int = Form(...),
priority: int = Form(1),
description: str = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Create new VPN profile."""
try:
service = VPNProfileService(db)
profile = service.create_profile(
gateway_id=gateway_id,
vpn_server_id=vpn_server_id,
name=name,
priority=priority,
description=description
)
flash(request, f"VPN-Profil '{name}' erstellt", "success")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles/{profile.id}", status_code=303)
except ValueError as e:
flash(request, str(e), "danger")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles/new", status_code=303)
except Exception as e:
flash(request, f"Fehler: {str(e)}", "danger")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles/new", status_code=303)
@router.get("/gateways/{gateway_id}/profiles/{profile_id}", response_class=HTMLResponse)
async def profile_detail(
request: Request,
gateway_id: int,
profile_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_user_web)
):
"""VPN profile detail page."""
profile = db.query(VPNProfile).filter(
VPNProfile.id == profile_id,
VPNProfile.gateway_id == gateway_id
).first()
if not profile:
flash(request, "Profil nicht gefunden", "danger")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)
return request.app.state.templates.TemplateResponse(
"gateways/profile_detail.html",
{
"request": request,
"current_user": current_user,
"gateway": profile.gateway,
"profile": profile,
"flash_messages": get_flashed_messages(request)
}
)
@router.get("/gateways/{gateway_id}/profiles/{profile_id}/edit", response_class=HTMLResponse)
async def edit_profile_form(
request: Request,
gateway_id: int,
profile_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Edit VPN profile form."""
profile = db.query(VPNProfile).filter(
VPNProfile.id == profile_id,
VPNProfile.gateway_id == gateway_id
).first()
if not profile:
flash(request, "Profil nicht gefunden", "danger")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)
# Get available VPN servers
if current_user.is_super_admin:
servers = db.query(VPNServer).filter(VPNServer.is_active == True).all()
else:
servers = db.query(VPNServer).filter(
(VPNServer.tenant_id == current_user.tenant_id) |
(VPNServer.tenant_id == None),
VPNServer.is_active == True
).all()
return request.app.state.templates.TemplateResponse(
"gateways/profile_form.html",
{
"request": request,
"current_user": current_user,
"gateway": profile.gateway,
"profile": profile,
"vpn_servers": servers,
"next_priority": profile.priority,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/gateways/{gateway_id}/profiles/{profile_id}/edit")
async def update_profile(
request: Request,
gateway_id: int,
profile_id: int,
name: str = Form(...),
priority: int = Form(1),
description: str = Form(None),
is_active: str = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Update VPN profile."""
profile = db.query(VPNProfile).filter(
VPNProfile.id == profile_id,
VPNProfile.gateway_id == gateway_id
).first()
if not profile:
flash(request, "Profil nicht gefunden", "danger")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)
profile.name = name
profile.description = description
profile.priority = priority
profile.is_active = is_active is not None # Checkbox sends "on" when checked, None when not
db.commit()
flash(request, f"Profil '{name}' aktualisiert", "success")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles/{profile_id}", status_code=303)
@router.get("/gateways/{gateway_id}/profiles/{profile_id}/provision")
async def provision_profile(
gateway_id: int,
profile_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_user_web)
):
"""Download OpenVPN config for a profile."""
service = VPNProfileService(db)
profile = service.get_profile_by_id(profile_id)
if not profile or profile.gateway_id != gateway_id:
return Response(status_code=404, content="Profile not found")
if not profile.is_ready:
return Response(status_code=400, content="Profile not ready for provisioning")
try:
config = service.provision_profile(profile)
filename = f"{profile.gateway.name}-{profile.name}.ovpn"
filename = filename.lower().replace(' ', '-')
return Response(
content=config,
media_type="application/x-openvpn-profile",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
except Exception as e:
return Response(status_code=500, content=str(e))
@router.post("/gateways/{gateway_id}/profiles/{profile_id}/priority")
async def update_priority(
request: Request,
gateway_id: int,
profile_id: int,
priority: int = Form(...),
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Update profile priority."""
service = VPNProfileService(db)
profile = service.get_profile_by_id(profile_id)
if not profile or profile.gateway_id != gateway_id:
flash(request, "Profil nicht gefunden", "danger")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)
service.set_priority(profile, priority)
flash(request, "Priorität aktualisiert", "success")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)
@router.post("/gateways/{gateway_id}/profiles/{profile_id}/revoke")
async def revoke_profile(
request: Request,
gateway_id: int,
profile_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Revoke profile certificate."""
service = VPNProfileService(db)
profile = service.get_profile_by_id(profile_id)
if not profile or profile.gateway_id != gateway_id:
flash(request, "Profil nicht gefunden", "danger")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)
service.revoke_profile(profile)
flash(request, f"Zertifikat für '{profile.name}' widerrufen", "warning")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)
@router.post("/gateways/{gateway_id}/profiles/{profile_id}/renew")
async def renew_profile(
request: Request,
gateway_id: int,
profile_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Renew profile certificate."""
service = VPNProfileService(db)
profile = service.get_profile_by_id(profile_id)
if not profile or profile.gateway_id != gateway_id:
flash(request, "Profil nicht gefunden", "danger")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)
service.renew_profile(profile)
flash(request, f"Zertifikat für '{profile.name}' erneuert", "success")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles/{profile_id}", status_code=303)
@router.post("/gateways/{gateway_id}/profiles/{profile_id}/delete")
async def delete_profile(
request: Request,
gateway_id: int,
profile_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Delete VPN profile."""
service = VPNProfileService(db)
profile = service.get_profile_by_id(profile_id)
if not profile or profile.gateway_id != gateway_id:
flash(request, "Profil nicht gefunden", "danger")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)
name = profile.name
service.delete_profile(profile)
flash(request, f"Profil '{name}' gelöscht", "warning")
return RedirectResponse(url=f"/gateways/{gateway_id}/profiles", status_code=303)
@router.get("/gateways/{gateway_id}/provision-all")
async def provision_all_profiles(
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_user_web)
):
"""Download all active profiles as a ZIP file."""
import io
import zipfile
service = VPNProfileService(db)
gateway = db.query(Gateway).filter(Gateway.id == gateway_id).first()
if not gateway:
return Response(status_code=404, content="Gateway not found")
configs = service.generate_all_configs_for_gateway(gateway_id)
if not configs:
return Response(status_code=400, content="No active profiles available")
# Create ZIP file in memory
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for filename, config in configs:
zip_file.writestr(filename, config)
zip_buffer.seek(0)
zip_filename = f"{gateway.name}-vpn-profiles.zip"
zip_filename = zip_filename.lower().replace(' ', '-')
return Response(
content=zip_buffer.getvalue(),
media_type="application/zip",
headers={"Content-Disposition": f"attachment; filename={zip_filename}"}
)
+349
View File
@@ -0,0 +1,349 @@
"""VPN Server management web routes."""
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from typing import Optional
from ..database import get_db
from ..models.user import User
from ..models.vpn_server import VPNServer, VPNProtocol, VPNCipher, VPNAuth, VPNCompression
from ..models.certificate_authority import CertificateAuthority, CAStatus
from ..services.vpn_server_service import VPNServerService
from .deps import require_admin_web, flash, get_flashed_messages
router = APIRouter()
@router.get("/vpn-servers", response_class=HTMLResponse)
async def list_vpn_servers(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""List all VPN servers."""
service = VPNServerService(db)
if current_user.is_super_admin:
servers = db.query(VPNServer).all()
else:
servers = service.get_servers_for_tenant(current_user.tenant_id)
return request.app.state.templates.TemplateResponse(
"vpn_servers/list.html",
{
"request": request,
"current_user": current_user,
"servers": servers,
"flash_messages": get_flashed_messages(request)
}
)
@router.get("/vpn-servers/new", response_class=HTMLResponse)
async def new_vpn_server_form(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""New VPN server form."""
# Get available CAs
if current_user.is_super_admin:
cas = db.query(CertificateAuthority).filter(
CertificateAuthority.status == CAStatus.ACTIVE
).all()
else:
cas = db.query(CertificateAuthority).filter(
(CertificateAuthority.tenant_id == current_user.tenant_id) |
(CertificateAuthority.tenant_id == None),
CertificateAuthority.status == CAStatus.ACTIVE
).all()
return request.app.state.templates.TemplateResponse(
"vpn_servers/form.html",
{
"request": request,
"current_user": current_user,
"server": None,
"cas": cas,
"protocols": VPNProtocol,
"ciphers": VPNCipher,
"auth_methods": VPNAuth,
"compression_options": VPNCompression,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/vpn-servers/new")
async def create_vpn_server(
request: Request,
name: str = Form(...),
hostname: str = Form(...),
ca_id: int = Form(...),
port: int = Form(1194),
protocol: str = Form("udp"),
vpn_network: str = Form("10.8.0.0"),
vpn_netmask: str = Form("255.255.255.0"),
cipher: str = Form("AES-256-GCM"),
auth: str = Form("SHA256"),
tls_version_min: str = Form("1.2"),
compression: str = Form("none"),
max_clients: int = Form(100),
keepalive_interval: int = Form(10),
keepalive_timeout: int = Form(60),
management_port: int = Form(7505),
is_primary: bool = Form(False),
description: str = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Create new VPN server."""
try:
service = VPNServerService(db)
tenant_id = None if current_user.is_super_admin else current_user.tenant_id
server = service.create_server(
name=name,
hostname=hostname,
ca_id=ca_id,
port=port,
protocol=VPNProtocol(protocol),
vpn_network=vpn_network,
vpn_netmask=vpn_netmask,
cipher=VPNCipher(cipher),
auth=VPNAuth(auth),
tls_version_min=tls_version_min,
compression=VPNCompression(compression),
max_clients=max_clients,
keepalive_interval=keepalive_interval,
keepalive_timeout=keepalive_timeout,
management_port=management_port,
is_primary=is_primary,
tenant_id=tenant_id,
description=description
)
flash(request, f"VPN-Server '{name}' erstellt", "success")
return RedirectResponse(url=f"/vpn-servers/{server.id}", status_code=303)
except ValueError as e:
flash(request, str(e), "danger")
return RedirectResponse(url="/vpn-servers/new", status_code=303)
except Exception as e:
flash(request, f"Fehler beim Erstellen: {str(e)}", "danger")
return RedirectResponse(url="/vpn-servers/new", status_code=303)
@router.get("/vpn-servers/{server_id}", response_class=HTMLResponse)
async def vpn_server_detail(
request: Request,
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""VPN server detail page."""
service = VPNServerService(db)
server = service.get_server_by_id(server_id)
if not server:
flash(request, "VPN-Server nicht gefunden", "danger")
return RedirectResponse(url="/vpn-servers", status_code=303)
# Check access
if not current_user.is_super_admin and server.tenant_id != current_user.tenant_id and server.tenant_id is not None:
flash(request, "Zugriff verweigert", "danger")
return RedirectResponse(url="/vpn-servers", status_code=303)
# Get status
status = service.get_server_status(server)
return request.app.state.templates.TemplateResponse(
"vpn_servers/detail.html",
{
"request": request,
"current_user": current_user,
"server": server,
"status": status,
"flash_messages": get_flashed_messages(request)
}
)
@router.get("/vpn-servers/{server_id}/edit", response_class=HTMLResponse)
async def edit_vpn_server_form(
request: Request,
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Edit VPN server form."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
flash(request, "VPN-Server nicht gefunden", "danger")
return RedirectResponse(url="/vpn-servers", status_code=303)
# Get available CAs
cas = db.query(CertificateAuthority).filter(
CertificateAuthority.status == CAStatus.ACTIVE
).all()
return request.app.state.templates.TemplateResponse(
"vpn_servers/form.html",
{
"request": request,
"current_user": current_user,
"server": server,
"cas": cas,
"protocols": VPNProtocol,
"ciphers": VPNCipher,
"auth_methods": VPNAuth,
"compression_options": VPNCompression,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/vpn-servers/{server_id}/edit")
async def update_vpn_server(
request: Request,
server_id: int,
name: str = Form(...),
hostname: str = Form(...),
port: int = Form(1194),
protocol: str = Form("udp"),
vpn_network: str = Form("10.8.0.0"),
vpn_netmask: str = Form("255.255.255.0"),
cipher: str = Form("AES-256-GCM"),
auth: str = Form("SHA256"),
tls_version_min: str = Form("1.2"),
compression: str = Form("none"),
max_clients: int = Form(100),
keepalive_interval: int = Form(10),
keepalive_timeout: int = Form(60),
management_port: int = Form(7505),
is_primary: bool = Form(False),
is_active: bool = Form(True),
description: str = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Update VPN server."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
flash(request, "VPN-Server nicht gefunden", "danger")
return RedirectResponse(url="/vpn-servers", status_code=303)
# Update fields
server.name = name
server.description = description
server.hostname = hostname
server.port = port
server.protocol = VPNProtocol(protocol)
server.vpn_network = vpn_network
server.vpn_netmask = vpn_netmask
server.cipher = VPNCipher(cipher)
server.auth = VPNAuth(auth)
server.tls_version_min = tls_version_min
server.compression = VPNCompression(compression)
server.max_clients = max_clients
server.keepalive_interval = keepalive_interval
server.keepalive_timeout = keepalive_timeout
server.management_port = management_port
server.is_primary = is_primary
server.is_active = is_active
# If setting as primary, unset other primaries
if is_primary:
db.query(VPNServer).filter(
VPNServer.tenant_id == server.tenant_id,
VPNServer.id != server.id
).update({"is_primary": False})
db.commit()
flash(request, "VPN-Server aktualisiert", "success")
return RedirectResponse(url=f"/vpn-servers/{server_id}", status_code=303)
@router.get("/vpn-servers/{server_id}/clients", response_class=HTMLResponse)
async def vpn_server_clients(
request: Request,
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Show connected clients for a VPN server."""
service = VPNServerService(db)
server = service.get_server_by_id(server_id)
if not server:
flash(request, "VPN-Server nicht gefunden", "danger")
return RedirectResponse(url="/vpn-servers", status_code=303)
clients = service.get_connected_clients(server)
return request.app.state.templates.TemplateResponse(
"vpn_servers/clients.html",
{
"request": request,
"current_user": current_user,
"server": server,
"clients": clients,
"flash_messages": get_flashed_messages(request)
}
)
@router.post("/vpn-servers/{server_id}/disconnect/{common_name}")
async def disconnect_client(
request: Request,
server_id: int,
common_name: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Disconnect a client from VPN server."""
service = VPNServerService(db)
server = service.get_server_by_id(server_id)
if not server:
flash(request, "VPN-Server nicht gefunden", "danger")
return RedirectResponse(url="/vpn-servers", status_code=303)
if service.disconnect_client(server, common_name):
flash(request, f"Client '{common_name}' getrennt", "success")
else:
flash(request, f"Konnte Client '{common_name}' nicht trennen", "danger")
return RedirectResponse(url=f"/vpn-servers/{server_id}/clients", status_code=303)
@router.post("/vpn-servers/{server_id}/delete")
async def delete_vpn_server(
request: Request,
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_web)
):
"""Delete VPN server."""
server = db.query(VPNServer).filter(VPNServer.id == server_id).first()
if not server:
flash(request, "VPN-Server nicht gefunden", "danger")
return RedirectResponse(url="/vpn-servers", status_code=303)
# Check if server has profiles
if server.vpn_profiles:
flash(request, "Server hat noch VPN-Profile. Bitte zuerst löschen.", "danger")
return RedirectResponse(url=f"/vpn-servers/{server_id}", status_code=303)
name = server.name
db.delete(server)
db.commit()
flash(request, f"VPN-Server '{name}' gelöscht", "warning")
return RedirectResponse(url="/vpn-servers", status_code=303)