umbau 1.0

This commit is contained in:
2026-09-02 20:30:44 +02:00
parent afe6719f51
commit 5c95b21be7
77 changed files with 10733 additions and 1914 deletions

View File

@ -4,10 +4,12 @@ Auslieferung zusammen (siehe Konzept 6.6 Web-Anwendungs-Hardening).
"""
from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, Request
from fastapi import Depends, FastAPI, Query, Request
from fastapi.responses import JSONResponse
from fastapi.openapi.utils import get_openapi
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
@ -18,9 +20,11 @@ from app.admin.routes import router as admin_router
from app.auth.deps import CurrentUser, require_global_admin
from app.auth.routes import router as auth_router
from app.catalog.routes import router as catalog_router
from app.db import close_db, init_db
from app.config import settings
from app.db import close_db, get_db, init_db
from app.rdp_proxy.ws_tunnel import router as rdp_ws_router
from app.security import log_stream
from app.security.session_reaper import reap_orphaned_sessions
from app.ssh_proxy.proxy import bcrypt_kdf_available
from app.ssh_proxy.sftp import router as sftp_router
from app.ssh_proxy.terminal_ws import router as ssh_ws_router
@ -56,6 +60,13 @@ async def lifespan(app: FastAPI):
log_stream.install()
_check_optional_dependencies()
await init_db()
# E7 (Umsetzungsauftrag_Sonnet5.md Teil E.1): Sitzungen, die beim letzten
# Absturz/Neustart offen waren, haben ended_at IS NULL fuer immer -- es
# gibt keinen Task mehr, der sie regulaer schliessen wuerde. Muss NACH
# init_db() laufen (Migrationen/Tabellen muessen existieren) und VOR dem
# ersten Request, damit die Sessionview und kuenftige
# "wie viele Sitzungen offen"-Zaehlungen (Teil F) nie mit Geistern rechnen.
await reap_orphaned_sessions(get_db())
yield
await close_db()
@ -88,7 +99,7 @@ async def security_headers_middleware(request: Request, call_next):
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self'; "
"img-src 'self' data:; "
"img-src 'self' data: blob:; "
"connect-src 'self' ws: wss:; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
@ -114,6 +125,15 @@ async def dashboard(request: Request):
return templates.TemplateResponse(request, "dashboard.html", {})
@app.get("/workspace", response_class=HTMLResponse)
async def workspace_page(request: Request):
# F3 (Umsetzungsauftrag_Sonnet5.md Teil F.3): dauerhafte Arbeitsflaeche
# mit Seitenleiste -- wie /dashboard rein statisches Markup, Auth
# clientseitig ueber GET /auth/me, Sitzungs-/Katalogdaten ueber die
# bestehenden /catalog/*-Endpunkte (app/catalog/routes.py).
return templates.TemplateResponse(request, "workspace.html", {})
@app.get("/admin", response_class=HTMLResponse)
async def admin_page(request: Request):
# Wie /dashboard: Seite selbst ist statisches Markup ohne Secrets, die
@ -134,8 +154,55 @@ async def rdp_page(request: Request, host_id: int):
@app.get("/healthz")
async def healthz():
return {"status": "ok"}
async def healthz(deep: bool = Query(default=False)):
"""Flache Variante (Standard): Prozess laeuft, sonst nichts -- schnell
genug fuer haeufige Load-Balancer-/Monitoring-Abfragen.
Tiefe Variante (`?deep=true`, Befund B.2 in Umsetzungsauftrag_Sonnet5.md
Teil B): prueft zusaetzlich die DB-Verbindung (billiges `SELECT 1` auf
der einen gemeinsamen Verbindung, siehe app/db.py) und guacd per
TCP-Verbindungsversuch mit kurzem Timeout. Vorher lieferte /healthz
IMMER "ok", auch wenn guacd tot war -- das fiel erst auf, wenn ein
Benutzer eine RDP-Sitzung versuchte. Bewusst NICHT als Standardverhalten,
damit ein ausgefallenes guacd nicht jede flache Liveness-Pruefung (die
ueblicherweise sehr haeufig laeuft) mit in den Fehlerzustand reisst.
"""
if not deep:
return {"status": "ok"}
checks: dict[str, str] = {}
healthy = True
try:
conn = get_db()
await asyncio.wait_for(conn.execute("SELECT 1"), timeout=2.0)
checks["db"] = "ok"
except Exception as exc:
checks["db"] = f"fehler: {exc.__class__.__name__}"
healthy = False
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(settings.guacd_host, settings.guacd_port), timeout=2.0
)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
checks["guacd"] = "ok"
except Exception as exc:
checks["guacd"] = (
f"nicht erreichbar unter {settings.guacd_host}:{settings.guacd_port} "
f"({exc.__class__.__name__})"
)
healthy = False
status_code = 200 if healthy else 503
return JSONResponse(
status_code=status_code,
content={"status": "ok" if healthy else "fehler", "checks": checks},
)
# --- API-Dokumentation --------------------------------------------------------