""" FastAPI-Einstiegspunkt. Bindet Security-Header, Router und Static/Template- Auslieferung zusammen (siehe Konzept 6.6 Web-Anwendungs-Hardening). """ from __future__ import annotations import logging from contextlib import asynccontextmanager from fastapi import Depends, FastAPI, Request from fastapi.openapi.utils import get_openapi from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from app.admin.log_ws import router as admin_log_ws_router 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.rdp_proxy.ws_tunnel import router as rdp_ws_router from app.security import log_stream from app.ssh_proxy.sftp import router as sftp_router from app.ssh_proxy.terminal_ws import router as ssh_ws_router logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") @asynccontextmanager async def lifespan(app: FastAPI): # Ring-Buffer-Handler fuers Live-'Verbindungslog' (Admin-only, siehe # app/security/log_stream.py) -- MUSS vor init_db() installiert werden, # damit auch fruehe Startmeldungen im Puffer landen. log_stream.install() await init_db() yield await close_db() app = FastAPI(title="Jumphost Gateway", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None) app.include_router(auth_router) app.include_router(admin_router) app.include_router(admin_log_ws_router) app.include_router(catalog_router) app.include_router(ssh_ws_router) app.include_router(sftp_router) app.include_router(rdp_ws_router) app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") @app.middleware("http") async def security_headers_middleware(request: Request, call_next): """Setzt die in Konzept 6.6 geforderten Security-Header auf jede Antwort. Laeuft unabhaengig davon, ob nginx vorgeschaltet ist (Defense-in-Depth -- nginx setzt in der Praxis dieselben Header zusaetzlich, siehe ansible/roles/nginx_proxy).""" response = await call_next(request) response.headers["X-Frame-Options"] = "DENY" response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Referrer-Policy"] = "no-referrer" response.headers["Permissions-Policy"] = "clipboard-read=(self), clipboard-write=(self), fullscreen=(self)" response.headers["Content-Security-Policy"] = ( "default-src 'self'; " "script-src 'self'; " "style-src 'self'; " "img-src 'self' data:; " "connect-src 'self' ws: wss:; " "frame-ancestors 'none'; " "base-uri 'self'; " "form-action 'self'" ) response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains" return response @app.get("/", response_class=HTMLResponse) async def index(request: Request): # Hinweis: seit Starlette >=1.x ist "TemplateResponse(request, name, ...)" # die aktuelle Aufrufkonvention (die alte "TemplateResponse(name, {...})" # wurde entfernt) -- beim Dependency-Upgrade im Rahmen der pip-audit- # Bereinigung angepasst und per Pentest-Testsuite regressionsgetestet. return templates.TemplateResponse(request, "login.html", {}) @app.get("/dashboard", response_class=HTMLResponse) async def dashboard(request: Request): # Auth-Pruefung erfolgt clientseitig ueber GET /auth/me (401 -> Redirect zu /); # serverseitig zusaetzlich abgesichert, sobald Templates dynamische Inhalte rendern. return templates.TemplateResponse(request, "dashboard.html", {}) @app.get("/admin", response_class=HTMLResponse) async def admin_page(request: Request): # Wie /dashboard: Seite selbst ist statisches Markup ohne Secrets, die # Admin-Pruefung (is_admin) erfolgt clientseitig ueber GET /auth/me # (Redirect zu /dashboard falls kein Admin) UND serverseitig hart auf # jedem einzelnen /admin/*-API-Aufruf (require_admin_or_scope). return templates.TemplateResponse(request, "admin.html", {}) @app.get("/terminal/{host_id}", response_class=HTMLResponse) async def terminal_page(request: Request, host_id: int): return templates.TemplateResponse(request, "terminal.html", {"host_id": host_id}) @app.get("/rdp/{host_id}", response_class=HTMLResponse) async def rdp_page(request: Request, host_id: int): return templates.TemplateResponse(request, "rdp.html", {"host_id": host_id}) @app.get("/healthz") async def healthz(): return {"status": "ok"} # --- API-Dokumentation -------------------------------------------------------- # # docs_url/redoc_url/openapi_url sind am FastAPI()-Konstruktor bewusst # deaktiviert (siehe oben) -- eine oeffentlich erreichbare API-Uebersicht # waere auf einem oeffentlich exponierten Jumphost unnoetige Informations- # preisgabe (Konzept 6.6). Stattdessen: eigene, auf eingeloggte Admins # beschraenkte Routen. Bewusst KEIN vendored/CDN-bezogenes Swagger-UI-Bundle # (Konzept 4.1/6.6: kein Laufzeit-CDN-Bezug im Browser) -- stattdessen eine # schlanke selbstgebaute Ansicht (templates/api_docs.html + # static/js/api-docs.js), die /openapi.json clientseitig ausliest. @app.get("/openapi.json", include_in_schema=False) async def protected_openapi_schema(admin: CurrentUser = Depends(require_global_admin)): return get_openapi(title=app.title, version=app.version, routes=app.routes) @app.get("/docs", response_class=HTMLResponse, include_in_schema=False) async def protected_api_docs(request: Request, admin: CurrentUser = Depends(require_global_admin)): return templates.TemplateResponse(request, "api_docs.html", {})