227 lines
9.5 KiB
Python
227 lines
9.5 KiB
Python
"""
|
|
FastAPI-Einstiegspunkt. Bindet Security-Header, Router und Static/Template-
|
|
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, Query, Request
|
|
from fastapi.responses import JSONResponse
|
|
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.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
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
logger = logging.getLogger("jumphost.main")
|
|
|
|
|
|
def _check_optional_dependencies() -> None:
|
|
"""Meldet fehlende, formal optionale Laufzeitabhaengigkeiten beim Start.
|
|
|
|
Bislang fiel das Fehlen von 'bcrypt' erst auf, wenn ein Benutzer eine
|
|
SSH-Sitzung startete -- und dann als englische asyncssh-Meldung, die nach
|
|
einem defekten Schluessel aussah. Die Warnung laeuft absichtlich NACH
|
|
log_stream.install(), damit sie auch im Live-Verbindungslog des
|
|
Adminbereichs sichtbar ist.
|
|
"""
|
|
if not bcrypt_kdf_available():
|
|
logger.error(
|
|
"Das Python-Modul 'bcrypt' fehlt im Virtualenv. Passphrasegeschuetzte "
|
|
"SSH-Schluessel im OpenSSH-Format (ssh-keygen-Standard) koennen damit "
|
|
"NICHT geladen werden. Behebung: pip install -r requirements.txt "
|
|
"(bzw. Ansible-Rolle python_runtime erneut ausrollen) und Dienst "
|
|
"neu starten."
|
|
)
|
|
|
|
|
|
@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()
|
|
_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()
|
|
|
|
|
|
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: blob:; "
|
|
"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("/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
|
|
# 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(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 --------------------------------------------------------
|
|
#
|
|
# 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", {})
|