From cd5957cbd2163c0138e4eae136786de29322d29d Mon Sep 17 00:00:00 2001 From: Midas Wollinger Date: Thu, 20 Aug 2026 17:10:19 +0200 Subject: [PATCH] more admin stuff 2 --- README.md | 112 +++++++- app/admin/routes.py | 234 +++++++++++++-- app/auth/deps.py | 37 +++ app/catalog/routes.py | 20 ++ app/db/migrations/0008_credentials_roles.sql | 19 ++ app/main.py | 5 + app/models/schemas.py | 11 +- app/rdp_proxy/ws_tunnel.py | 14 + app/security/active_sessions.py | 46 +++ app/security/log_stream.py | 71 +++++ app/ssh_proxy/proxy.py | 57 +++- app/ssh_proxy/terminal_ws.py | 14 + static/css/app.css | 5 + static/js/admin.js | 174 +++++++++-- static/js/dashboard.js | 35 +++ templates/admin.html | 47 ++- templates/dashboard.html | 5 +- tests/test_admin_groups_tokens.py | 6 +- tests/test_phase9.py | 286 +++++++++++++++++++ 19 files changed, 1120 insertions(+), 78 deletions(-) create mode 100644 app/db/migrations/0008_credentials_roles.sql create mode 100644 app/security/active_sessions.py create mode 100644 app/security/log_stream.py create mode 100644 tests/test_phase9.py diff --git a/README.md b/README.md index 8a6edb2..82bfafd 100644 --- a/README.md +++ b/README.md @@ -186,13 +186,72 @@ Das Entziehen bleibt bewusst pro Zeile/Rolle (`role_name`, Einzahl) — das entspricht dem bestehenden "Entziehen"-Knopf pro Tabellenzeile und braucht keine Mehrfachauswahl. +## Phase 9: Host-Key-Bugfix, Sessionview, Live-Verbindungslog, Credentials-RBAC + +Sechs zusammenhaengende Punkte aus einer erneuten nummerierten Anfrage, in +derselben Session direkt nach Phase 8 umgesetzt: + +**1) Bugfix "Host Key ermitteln" (Fehler 500)**: `discover_and_store_host_key()` +(`app/ssh_proxy/proxy.py`) verband sich zwar erfolgreich fuer den Key-Exchange, +liess aber den danach IMMER folgenden Authentifizierungsfehler (kein +Client-Key fuer die reine Discovery hinterlegt) unbehandelt durchreichen — +das riss als unbehandelte Exception bis zu FastAPI durch und fuehrte praktisch +bei jedem Klick auf "Host-Key ermitteln" zu einem 500. Fix: der waehrend des +Key-Exchange bereits erfasste Fingerprint gilt jetzt trotz des danach +erwarteten Auth-Fehlers als Erfolg; nur ein echter Verbindungsfehler VOR dem +Key-Exchange (Host nicht erreichbar, DNS, Timeout) liefert jetzt sauber 502 +statt 500 (neue `HostKeyDiscoveryError`). + +**2) Login-Verlauf entfernt**: der in Phase 8 hinzugefuegte Tab "Login-Verlauf" +wurde aus der Admin-Oberflaeche entfernt (Login-/Logout-/Fehlversuch-Ereignisse +bleiben weiterhin vollstaendig im Audit-Log-Tab sichtbar, dort war die +Information ohnehin redundant vorhanden). + +**3) Verbindungslog (Live-Tail, inkl. Debug)**: neuer Tab "Verbindungslog" + +WebSocket `GET /admin/ws/logs` streamen die Anwendungslogs dieses +Server-Prozesses live in den Browser — inkl. DEBUG-Detail zum SSH/RDP- +Verbindungsaufbau (`jumphost.*`-Logger werden beim Start auf DEBUG gesetzt, +siehe `app/security/log_stream.py`). Bewusst ein reiner In-Memory-Ring-Buffer +(letzte 1000 Zeilen) + Pub/Sub ohne DB-Persistenz — ein Live-Tail wie +`journalctl -f`, kein durchsuchbares Archiv. Nur fuer Super-Admins sichtbar/ +erreichbar, da die Logs mandantenuebergreifend technische Details preisgeben +koennen. + +**4) Sessionview-Dashboard (nur Super-Admin)**: neuer Tab "Sessions" + +`GET /admin/sessions` (aktive + historische Sitzungen ueber alle Mandanten), +`POST /admin/sessions/{id}/terminate` (zwangsweises Trennen einer laufenden +Sitzung) und `GET /admin/sessions/{id}/recording` (Integritaetspruefung der +Aufzeichnung). "Beenden" nutzt eine neue prozesslokale Registry +(`app/security/active_sessions.py`) + `asyncio.Task.cancel()` auf die +WebSocket-Route der Sitzung, was den bestehenden Cleanup-Pfad (DB-Update, +Audit-Log-Eintrag, WebSocket schliessen) unveraendert durchlaufen laesst — +funktioniert nur fuer Sitzungen auf demselben Server-Prozess (`killable`-Feld +in der Response zeigt das an, statt einen Beenden-Versuch fehlschlagen zu +lassen). + +**5) Hostgruppen/Server im Menue getrennt**: der bisherige kombinierte Tab +"Hosts & Verbindungen" wurde in zwei eigene Tabs "Hostgruppen" und "Server" +aufgeteilt (gleicher Ladepfad/gleiche Formulare, nur die Navigation ist jetzt +getrennt). + +**6) Credentials ins RBAC-Modell**: zwei neue Rollen `credentials_view`/ +`credentials_manage` (Migration `0008_credentials_roles.sql`), die — wie +`ssh_connect`/`rdp_connect`/`file_transfer` — pro Hostgruppe an einzelne User +oder Benutzergruppen vergeben werden koennen. Damit koennen auch NICHT-Admins +gezielt Zugangsdaten (RDP-Passwort setzen/entfernen, SSH-Key-Zuordnung, nie +der Klartext selbst) fuer Hosts "ihrer" Hostgruppe verwalten, ohne Admin oder +Mandanten-Admin sein zu muessen (neue Dependency +`require_admin_scope_or_host_role` in `app/auth/deps.py`, neuer Endpunkt +`GET /admin/hosts/{id}/credentials`, Dashboard zeigt einen "Zugangsdaten"- +Knopf bei Hosts mit dieser Rolle). + ## Tests ```bash pytest -q ``` -61 Tests (vorher 46) decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain +68 Tests (vorher 61) decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain (inkl. Manipulationserkennung und Trigger-Durchsetzung), RBAC-Logik inkl. Ablaufdaten, den vollstaendigen Login-Flow (Passwort -> TOTP-Enrollment -> Session-Cookie -> geschuetzte Endpunkte) gegen die echte FastAPI-App, 17 @@ -216,13 +275,30 @@ Loeschen bei vorhandener Audit-Historie vs. echtem Hard-Delete ohne), Update/Delete fuer Hostgruppen (blockiert solange Hosts enthalten sind), Hosts (Soft- vs. Hard-Delete), SSH-Keys (inkl. Rotation) und Benutzergruppen, sowie den neuen `GET /admin/hosts/{id}`-Detailendpunkt inkl. SSH-Key- -Zuordnungen und RDP-Zugangsdaten-Status. +Zuordnungen und RDP-Zugangsdaten-Status. 7 weitere neue Tests zu Phase 9 +(`tests/test_phase9.py`) decken zusaetzlich ab: dass "Host-Key ermitteln" bei +einem echten Verbindungsfehler 502 statt 500 liefert und bei einem Auth-Fehler +NACH erfolgreichem Key-Exchange weiterhin als Erfolg gilt; dass +`/admin/sessions*` ausschliesslich Super-Admins erlaubt ist (Mandanten-Admin += 403) und `terminate`/`recording` fuer nicht (mehr) laufende bzw. nicht +aufgezeichnete Sitzungen sauber 409/404 statt einen internen Fehler liefern; +dass die neuen Rollen `credentials_view`/`credentials_manage` Nicht-Admins +gezielten Lese- bzw. Lese+Schreib-Zugriff auf Zugangsdaten geben (und ein User +ganz ohne diese Rolle weiterhin 403 bekommt), inkl. des neuen +`can_view_credentials`-Felds in `GET /catalog/hosts`. Nebenbei beim +Gegenpruefen entdeckt und mitkorrigiert: drei Tests in +`tests/test_admin_groups_tokens.py` sendeten noch das VOR Phase 8 gueltige +Payload-Feld `role_name` (Einzahl) an `/admin/group-roles/grant`, das +`GroupRoleGrantRequest` seit der Umstellung auf Mehrfachauswahl aber gar nicht +mehr kennt (erwartet `role_names`, eine Liste) — ein beim Phase-8-Umbau +liegen gebliebener Regressionsfehler, jetzt auf `role_names` korrigiert. -> **Hinweis:** Alle 34 in dieser und der vorherigen Session neu -> hinzugekommenen Tests (5 CSP + 14 Admin/Gruppen/Token + 15 Mandanten/CRUD) -> konnten in der verwendeten Cloud-Sandbox nicht mit `pytest -q` ausgefuehrt -> werden, da diese Sandbox keinen Netzwerkzugriff auf PyPI hat und -> `fastapi`/`aiosqlite`/`argon2`/`asyncssh` dort nicht vorinstalliert sind. +> **Hinweis:** Alle 41 in dieser und den beiden vorherigen Sessions neu +> hinzugekommenen Tests (5 CSP + 14 Admin/Gruppen/Token + 15 Mandanten/CRUD + +> 7 Phase 9) konnten in der verwendeten Cloud-Sandbox nicht mit `pytest -q` +> ausgefuehrt werden, da diese Sandbox keinen Netzwerkzugriff auf PyPI hat und +> `fastapi`/`aiosqlite`/`argon2`/`asyncssh` dort nicht vorinstalliert sind +> (auch nicht ueber die Geraete-Bruecke zum lokalen Rechner erreichbar). > Fuer Phase 8 wurde stattdessen ein tieferes Verifikationsverfahren > angewendet als in der vorherigen Session: minimale Stub-Module fuer die > vier fehlenden Pakete (`fastapi`s `APIRouter`-Dekoratoren als No-Ops, ein @@ -236,12 +312,22 @@ Zuordnungen und RDP-Zugangsdaten-Status. > selbst nutzt weiterhin `httpx.AsyncClient` gegen die echte ASGI-App (wie > alle anderen Testdateien) und wurde daher zeilenweise gegen die tatsaechliche > Endpunkt-Implementierung gegengeprueft (Pfade, Payload-Felder, Statuscodes), -> aber nicht selbst mit `pytest` ausgefuehrt. Alle geaenderten Python-/ -> JS-Dateien wurden mit `py_compile`/`node --check` auf Syntaxfehler geprueft, -> und jeder `document.getElementById`-Aufruf in `admin.js`/`terminal.js` wurde -> automatisiert gegen die tatsaechlichen HTML-IDs in `admin.html`/`terminal.html` -> abgeglichen (0 Abweichungen). Bitte `pytest -q` lokal ausfuehren und -> Ergebnis melden. +> aber nicht selbst mit `pytest` ausgefuehrt. Fuer Phase 9 zusaetzlich: die +> reparierte Kontrollfluss-Logik von `discover_and_store_host_key()` wurde +> mit einem eigenstaendigen Fake-`asyncssh`-Nachbau isoliert durchgespielt +> (alle drei Faelle: Verbindungsfehler vor Key-Exchange, Auth-Fehler NACH +> Key-Exchange = der eigentliche Bug, voller Erfolg), Migration +> `0008_credentials_roles.sql` wurde sowohl gegen eine leere als auch gegen +> eine bereits mit 0001-0007 befuellte In-Memory-DB angewendet (Lehre aus dem +> Migrations-Vorfall in Phase 8, siehe unten), und die neuen, rein +> In-Process-Module `app/security/log_stream.py` und +> `app/security/active_sessions.py` (kein DB-/HTTP-Bezug) wurden direkt +> importiert und gegen echtes `asyncio`/`logging` ausgefuehrt statt nur +> gelesen. Alle geaenderten Python-/JS-Dateien wurden mit +> `py_compile`/`node --check` auf Syntaxfehler geprueft, und jeder +> `document.getElementById`-Aufruf in `admin.js` wurde automatisiert gegen +> die tatsaechlichen HTML-IDs in `admin.html` abgeglichen (0 Abweichungen). +> Bitte `pytest -q` lokal ausfuehren und Ergebnis melden. Manuell zusaetzlich verifiziert (siehe Entwicklungs-Log dieser Session): Server-Start, Static-/Template-Auslieferung, Security-Header, vollstaendiger diff --git a/app/admin/routes.py b/app/admin/routes.py index f3457d5..54b4f8d 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -21,13 +21,17 @@ Privilege-Escalation-Schutz). from __future__ import annotations import json +import logging +from pathlib import Path -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status from app.auth.deps import ( CurrentUser, effective_tenant_ids, + get_current_user_ws, require_admin_or_scope, + require_admin_scope_or_host_role, require_admin_session, require_global_admin, ) @@ -54,6 +58,7 @@ from app.models.schemas import ( UserGroupUpdateRequest, UserUpdateRequest, ) +from app.security import active_sessions, log_stream from app.security.api_tokens import ( VALID_SCOPES, generate_token, @@ -64,9 +69,12 @@ from app.security.api_tokens import ( from app.security.audit import verify_chain, write_audit_event from app.security.crypto import encrypt_secret from app.security.passwords import hash_password -from app.ssh_proxy.proxy import discover_and_store_host_key +from app.recordings.recorder import verify_recording +from app.ssh_proxy.proxy import HostKeyDiscoveryError, HostNotConfiguredError, discover_and_store_host_key from app.tenancy import TenantScope, resolve_host_group_tenant, resolve_host_tenant, tenant_user_ids +logger = logging.getLogger("jumphost.admin") + router = APIRouter(prefix="/admin", tags=["admin"]) @@ -900,10 +908,23 @@ async def discover_host_key( """ACHTUNG: Verbindet einmalig OHNE Host-Key-Pruefung, um den Fingerprint zu erfassen (bewusste Trust-Entscheidung, siehe Konzept 4.2). Danach gilt fuer alle regulaeren Verbindungen wieder striktes Pinning. Jeder Aufruf wird - prominent im Audit-Log vermerkt.""" + prominent im Audit-Log vermerkt. + + Bugfix: discover_and_store_host_key() konnte bisher ein unbehandeltes + asyncssh/OSError durchreichen -> FastAPI antwortete mit 500 statt einer + verwertbaren Fehlermeldung (siehe app/ssh_proxy/proxy.py). Jetzt sauber + auf 502 (Verbindung fehlgeschlagen) bzw. 400 (kein SSH-Host) gemappt.""" conn = get_db() await _assert_host_in_scope(conn, _scope(admin), host_id) - fingerprint = await discover_and_store_host_key(conn, host_id, admin_user_id=admin.id) + try: + fingerprint = await discover_and_store_host_key(conn, host_id, admin_user_id=admin.id) + except HostNotConfiguredError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc + except HostKeyDiscoveryError as exc: + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, + f"Host-Key konnte nicht ermittelt werden -- Ziel nicht erreichbar: {exc.reason}", + ) from exc await write_audit_event( conn, event_type="host_key_discovered_trust_decision", user_id=admin.id, client_ip=_client_ip(request), details={"host_id": host_id, "fingerprint": fingerprint}, @@ -915,12 +936,18 @@ async def discover_host_key( @router.put("/hosts/{host_id}/rdp-credentials") async def set_rdp_credentials( host_id: int, payload: RdpCredentialsRequest, request: Request, - admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")), + admin: CurrentUser = Depends( + require_admin_scope_or_host_role("hosts", "write", ("credentials_manage",)) + ), ): """Speichert/rotiert das RDP-Passwort fuer einen Host, verschluesselt mit - dem KEK (eigener AAD-Kontext, siehe app/security/crypto.py).""" + dem KEK (eigener AAD-Kontext, siehe app/security/crypto.py). Zugriff: + Admin/Mandanten-Admin/Token ODER ein Nicht-Admin mit Rolle + 'credentials_manage' auf der Hostgruppe dieses Hosts (siehe RBAC- + Erweiterung 'Credentials ins RBAC-Modell').""" conn = get_db() - await _assert_host_in_scope(conn, _scope(admin), host_id) + if admin.is_any_admin: + await _assert_host_in_scope(conn, _scope(admin), host_id) encrypted = encrypt_secret(payload.password.encode(), associated_data=b"rdp_password") await conn.execute( "INSERT INTO rdp_credentials (host_id, password_enc, updated_at) " @@ -940,10 +967,13 @@ async def set_rdp_credentials( @router.delete("/hosts/{host_id}/rdp-credentials") async def delete_rdp_credentials( host_id: int, request: Request, - admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")), + admin: CurrentUser = Depends( + require_admin_scope_or_host_role("hosts", "write", ("credentials_manage",)) + ), ): conn = get_db() - await _assert_host_in_scope(conn, _scope(admin), host_id) + if admin.is_any_admin: + await _assert_host_in_scope(conn, _scope(admin), host_id) await conn.execute("DELETE FROM rdp_credentials WHERE host_id = ?", (host_id,)) await write_audit_event( conn, event_type="rdp_credentials_deleted", user_id=admin.id, client_ip=_client_ip(request), @@ -953,6 +983,42 @@ async def delete_rdp_credentials( return {"status": "ok"} +@router.get("/hosts/{host_id}/credentials") +async def get_host_credentials( + host_id: int, + admin: CurrentUser = Depends( + require_admin_scope_or_host_role("hosts", "read", ("credentials_view", "credentials_manage")) + ), +): + """Zugangsdaten-Uebersicht fuer EINEN Host (zugeordnete SSH-Keys, ob ein + RDP-Passwort gesetzt ist -- niemals der Klartext selbst, siehe Konzept + 6.4). Im Unterschied zu GET /admin/hosts/{id} (voller Datensatz, + admin-only) ist dieser schlanke Endpunkt bewusst auch fuer Nicht-Admins + mit Rolle 'credentials_view'/'credentials_manage' erreichbar (RBAC- + Erweiterung 'Credentials ins RBAC-Modell').""" + conn = get_db() + if admin.is_any_admin: + await _assert_host_in_scope(conn, _scope(admin), host_id) + else: + host_row = await (await conn.execute("SELECT 1 FROM hosts WHERE id = ?", (host_id,))).fetchone() + if host_row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Host nicht gefunden") + keys_cursor = await conn.execute( + "SELECT sk.id, sk.label FROM host_ssh_key_map m JOIN ssh_keys sk ON sk.id = m.ssh_key_id " + "WHERE m.host_id = ?", + (host_id,), + ) + ssh_keys = [{"id": k[0], "label": k[1]} for k in await keys_cursor.fetchall()] + rdp_row = await (await conn.execute( + "SELECT updated_at FROM rdp_credentials WHERE host_id = ?", (host_id,) + )).fetchone() + return { + "host_id": host_id, "ssh_keys": ssh_keys, + "rdp_credentials_set": rdp_row is not None, + "rdp_credentials_updated_at": rdp_row[0] if rdp_row else None, + } + + @router.get("/rdp-credentials") async def list_rdp_credentials(admin: CurrentUser = Depends(require_admin_or_scope("hosts", "read"))): """Uebersicht aller RDP/Windows-Hosts fuer den 'Zugangsdaten'-Tab: welche @@ -1281,12 +1347,19 @@ async def delete_ssh_key( @router.post("/hosts/{host_id}/ssh-keys/{key_id}") async def map_ssh_key_to_host( host_id: int, key_id: int, request: Request, - admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")), + admin: CurrentUser = Depends( + require_admin_scope_or_host_role("ssh_keys", "write", ("credentials_manage",)) + ), ): conn = get_db() - scope = _scope(admin) - await _assert_host_in_scope(conn, scope, host_id) - await _assert_ssh_key_in_scope(conn, scope, key_id) + if admin.is_any_admin: + scope = _scope(admin) + await _assert_host_in_scope(conn, scope, host_id) + await _assert_ssh_key_in_scope(conn, scope, key_id) + else: + key_row = await (await conn.execute("SELECT 1 FROM ssh_keys WHERE id = ?", (key_id,))).fetchone() + if key_row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "SSH-Key nicht gefunden") await conn.execute( "INSERT OR IGNORE INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (?, ?)", (host_id, key_id), @@ -1302,11 +1375,13 @@ async def map_ssh_key_to_host( @router.delete("/hosts/{host_id}/ssh-keys/{key_id}") async def unmap_ssh_key_from_host( host_id: int, key_id: int, request: Request, - admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")), + admin: CurrentUser = Depends( + require_admin_scope_or_host_role("ssh_keys", "write", ("credentials_manage",)) + ), ): conn = get_db() - scope = _scope(admin) - await _assert_host_in_scope(conn, scope, host_id) + if admin.is_any_admin: + await _assert_host_in_scope(conn, _scope(admin), host_id) await conn.execute( "DELETE FROM host_ssh_key_map WHERE host_id = ? AND ssh_key_id = ?", (host_id, key_id) ) @@ -1463,3 +1538,130 @@ async def verify_audit_log(admin: CurrentUser = Depends(require_admin_or_scope(" conn = get_db() intact, broken_at = await verify_chain(conn) return {"intact": intact, "first_broken_id": broken_at} + + +# --- Sessionview (nur Super-Admin) -------------------------------------------- +# +# Zeigt ALLE Sitzungen ueber alle Mandanten hinweg -- bewusst require_global_ +# admin (kein Mandanten-Admin, kein Token), da Sitzungsdaten (Client-IP, +# genutzter Host) ueber Mandantengrenzen hinweg sichtbar waeren. 'Beenden' +# nutzt app/security/active_sessions.py (siehe dort fuer die Prozess-lokale +# Registry und die genaue Beenden-Semantik per asyncio.Task.cancel()). + +@router.get("/sessions") +async def list_sessions( + active_only: bool = False, limit: int = 200, offset: int = 0, + admin: CurrentUser = Depends(require_global_admin), +): + limit = max(1, min(limit, 1000)) + conn = get_db() + where = "WHERE s.ended_at IS NULL" if active_only else "" + cursor = await conn.execute( + f""" + SELECT s.id, s.user_id, u.username, s.host_id, h.hostname, hg.name, + s.protocol, s.started_at, s.ended_at, s.client_ip, s.end_reason, + s.recording_path + FROM sessions s + JOIN users u ON u.id = s.user_id + JOIN hosts h ON h.id = s.host_id + JOIN host_groups hg ON hg.id = h.host_group_id + {where} + ORDER BY s.started_at DESC LIMIT ? OFFSET ? + """, + (limit, offset), + ) + rows = await cursor.fetchall() + active_ids = active_sessions.all_ids() + result = [] + for r in rows: + recording_path = r[11] + result.append({ + "id": r[0], "user_id": r[1], "username": r[2], "host_id": r[3], + "hostname": r[4], "host_group_name": r[5], "protocol": r[6], + "started_at": r[7], "ended_at": r[8], "client_ip": r[9], "end_reason": r[10], + "is_active": r[8] is None, + "killable": r[0] in active_ids, + "has_recording": bool(recording_path) and Path(recording_path).exists(), + }) + return result + + +@router.post("/sessions/{session_id}/terminate") +async def terminate_session( + session_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin), +): + conn = get_db() + row = await (await conn.execute( + "SELECT ended_at, user_id, host_id FROM sessions WHERE id = ?", (session_id,) + )).fetchone() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Sitzung nicht gefunden") + if row[0] is not None: + raise HTTPException(status.HTTP_409_CONFLICT, "Sitzung ist bereits beendet") + entry = active_sessions.get(session_id) + if entry is None: + raise HTTPException( + status.HTTP_409_CONFLICT, + "Sitzung laeuft nicht auf diesem Server-Prozess (evtl. anderer Worker) " + "und kann von hier aus nicht beendet werden", + ) + entry.task.cancel() + await write_audit_event( + conn, event_type="session_terminated_by_admin", user_id=admin.id, client_ip=_client_ip(request), + details={"session_id": session_id, "target_user_id": row[1], "host_id": row[2]}, + ) + await conn.commit() + return {"status": "ok"} + + +@router.get("/sessions/{session_id}/recording") +async def get_session_recording(session_id: int, admin: CurrentUser = Depends(require_global_admin)): + """Prueft und liefert die Hash-verkettete Sitzungsaufzeichnung (siehe + app/recordings/recorder.py) -- bewusst nur Metadaten + Integritaetsstatus, + kein vollstaendiger Tastatur-/Bildschirm-Dump in der Response (Konzept + 6.5: Aufzeichnungen sind hochsensibel).""" + conn = get_db() + row = await (await conn.execute( + "SELECT recording_path FROM sessions WHERE id = ?", (session_id,) + )).fetchone() + if row is None or not row[0]: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Keine Aufzeichnung fuer diese Sitzung vorhanden") + path = Path(row[0]) + if not path.exists(): + raise HTTPException(status.HTTP_404_NOT_FOUND, "Aufzeichnungsdatei nicht (mehr) vorhanden") + try: + verified = verify_recording(path) + except Exception: + logger.exception("Aufzeichnung %s konnte nicht gelesen/verifiziert werden", session_id) + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Aufzeichnung konnte nicht gelesen werden") + with open(path, encoding="utf-8") as fh: + entry_count = sum(1 for line in fh if line.strip()) + return {"session_id": session_id, "verified": verified, "entry_count": entry_count} + + +# --- Verbindungslog (nur Super-Admin, Live-Tail) ------------------------------ +# +# Reiner Live-Tail der Anwendungslogs dieses Server-Prozesses (siehe +# app/security/log_stream.py) -- kein durchsuchbares Archiv, keine +# Persistenz. Nur Super-Admin (require_global_admin-Semantik), da die Logs +# ueber alle Mandanten hinweg technische Details preisgeben koennen. + +@router.websocket("/ws/logs") +async def stream_logs(websocket: WebSocket): + user = await get_current_user_ws(websocket) + if user is None or not user.is_admin: + await websocket.close(code=4403) + return + + await websocket.accept() + queue = log_stream.subscribe() + try: + for line in log_stream.recent_lines(): + await websocket.send_json({"type": "line", "line": line}) + while True: + line = await queue.get() + await websocket.send_json({"type": "line", "line": line}) + except WebSocketDisconnect: + pass + finally: + log_stream.unsubscribe(queue) diff --git a/app/auth/deps.py b/app/auth/deps.py index daaa059..9be6173 100644 --- a/app/auth/deps.py +++ b/app/auth/deps.py @@ -204,6 +204,43 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr return CurrentUser(id=user_id, username=username, is_admin=False, token_tenant_id=tenant_id) +def require_admin_scope_or_host_role(resource: str, action: str, role_names: tuple[str, ...]): + """Wie require_admin_or_scope, erlaubt zusaetzlich einen regulaeren + (Nicht-Admin-)User per Session, wenn er fuer den per Pfadparameter + 'host_id' angegebenen Host mindestens eine der uebergebenen Hostgruppen- + Rollen besitzt (siehe RBAC-Erweiterung 'Credentials ins RBAC-Modell': + Rollen 'credentials_view'/'credentials_manage', app/models/schemas.py). + Token-Auth bleibt unveraendert ausschliesslich ueber den Admin-Scope + moeglich -- kein Token traegt je eine Hostgruppen-Rolle.""" + + async def _dep( + host_id: int, + response: Response, + jh_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME), + authorization: str | None = Header(default=None), + ) -> CurrentUser: + if authorization and authorization.startswith("Bearer "): + token = authorization.removeprefix("Bearer ").strip() + principal = await _validate_api_token(token, resource=resource, action=action) + if principal is None: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + "Ungueltiges, abgelaufenes oder nicht ausreichend berechtigtes Token", + ) + return principal + + user = await get_current_user(response, jh_session) + if user.is_any_admin: + return user + conn = get_db() + for role_name in role_names: + if await user_has_role_for_host(conn, user_id=user.id, host_id=host_id, role_name=role_name): + return user + raise HTTPException(status.HTTP_403_FORBIDDEN, "Keine Berechtigung fuer diese Zugangsdaten") + + return _dep + + def require_admin_or_scope(resource: str, action: str): """Dependency-Factory fuer die Admin-API: erlaubt Zugriff entweder ueber eine eingeloggte Admin-Session (Super- ODER Mandanten-Admin, Cookie) ODER diff --git a/app/catalog/routes.py b/app/catalog/routes.py index 9203cad..5f7a6a6 100644 --- a/app/catalog/routes.py +++ b/app/catalog/routes.py @@ -30,6 +30,7 @@ async def my_hosts(user: CurrentUser = Depends(get_current_user)): for h in hosts: h["can_connect"] = True h["can_file_transfer"] = True + h["can_view_credentials"] = True return hosts cursor = await conn.execute( @@ -82,7 +83,26 @@ async def my_hosts(user: CurrentUser = Depends(get_current_user)): (user.id, user.id), ) ft_groups = {row[0] for row in await ft_cursor.fetchall()} + + cred_cursor = await conn.execute( + """ + SELECT uhr.host_group_id FROM user_hostgroup_roles uhr + JOIN roles r ON r.id = uhr.role_id + WHERE uhr.user_id = ? AND r.name IN ('credentials_view', 'credentials_manage') + AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now')) + UNION + SELECT ghr.host_group_id FROM group_hostgroup_roles ghr + JOIN roles r ON r.id = ghr.role_id + JOIN user_group_members ugm ON ugm.user_group_id = ghr.user_group_id + WHERE ugm.user_id = ? AND r.name IN ('credentials_view', 'credentials_manage') + AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now')) + """, + (user.id, user.id), + ) + cred_groups = {row[0] for row in await cred_cursor.fetchall()} + for h in hosts: h["can_connect"] = True h["can_file_transfer"] = h["host_group_id"] in ft_groups and bool(h["file_transfer_enabled"]) + h["can_view_credentials"] = h["host_group_id"] in cred_groups return hosts diff --git a/app/db/migrations/0008_credentials_roles.sql b/app/db/migrations/0008_credentials_roles.sql new file mode 100644 index 0000000..4b8f9b0 --- /dev/null +++ b/app/db/migrations/0008_credentials_roles.sql @@ -0,0 +1,19 @@ +-- RBAC-Erweiterung "Credentials ins RBAC-Modell aufnehmen": zwei neue Rollen, +-- die -- wie ssh_connect/rdp_connect/file_transfer/clipboard -- pro +-- Hostgruppe an einzelne User oder Benutzergruppen vergeben werden koennen +-- (user_hostgroup_roles / group_hostgroup_roles, unveraendert). Damit koennen +-- auch Nicht-Admins gezielt Zugriff auf Zugangsdaten (SSH-Keys, RDP-Passwoerter) +-- der Hosts "ihrer" Hostgruppe bekommen, ohne globaler oder Mandanten-Admin +-- sein zu muessen. Durchsetzung: app/auth/deps.py::require_admin_scope_or_host_role, +-- verwendet in app/admin/routes.py fuer die RDP-Credential- und SSH-Key- +-- Zuordnungs-Endpunkte sowie den neuen GET /admin/hosts/{id}/credentials. +-- +-- credentials_view: darf sehen, OB/wann ein RDP-Passwort gesetzt ist und +-- welche SSH-Keys einem Host zugeordnet sind (NIE den +-- Klartext selbst -- der wird ueber KEINEN Endpunkt +-- jemals im Klartext zurueckgegeben, siehe Konzept 6.4). +-- credentials_manage: wie credentials_view, zusaetzlich RDP-Passwort setzen/ +-- loeschen und SSH-Keys einem Host zuordnen/entfernen. +INSERT OR IGNORE INTO roles (id, name) VALUES + (7, 'credentials_view'), + (8, 'credentials_manage'); diff --git a/app/main.py b/app/main.py index 8812807..945bb07 100644 --- a/app/main.py +++ b/app/main.py @@ -19,6 +19,7 @@ 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 @@ -27,6 +28,10 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name @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() diff --git a/app/models/schemas.py b/app/models/schemas.py index f74e538..5351686 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -18,6 +18,13 @@ HOSTNAME_LABEL_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$") ROLE_NAME = Literal[ "ssh_connect", "rdp_connect", "file_transfer", "clipboard", "session_recording_view", "admin_hostgroup", + # Hinzugefuegt fuer RBAC-Erweiterung 'Credentials ins RBAC-Modell' (Migration + # 0008_credentials_roles.sql): erlaubt Nicht-Admins mit dieser Rolle auf + # einer Hostgruppe gezielten Zugriff auf Zugangsdaten (SSH-Keys, RDP- + # Passwoerter) der Hosts dieser Gruppe, siehe app/auth/deps.py:: + # require_admin_scope_or_host_role und admin/routes.py (rdp-credentials, + # ssh-keys-Zuordnung, GET /admin/hosts/{id}/credentials). + "credentials_view", "credentials_manage", ] @@ -146,7 +153,7 @@ class HostUpdateRequest(BaseModel): class RoleGrantRequest(BaseModel): user_id: int host_group_id: int - role_names: list[ROLE_NAME] = Field(min_length=1, max_length=6) + role_names: list[ROLE_NAME] = Field(min_length=1, max_length=8) expires_at: str | None = None @@ -174,7 +181,7 @@ class GroupMemberRequest(BaseModel): class GroupRoleGrantRequest(BaseModel): user_group_id: int host_group_id: int - role_names: list[ROLE_NAME] = Field(min_length=1, max_length=6) + role_names: list[ROLE_NAME] = Field(min_length=1, max_length=8) expires_at: str | None = None diff --git a/app/rdp_proxy/ws_tunnel.py b/app/rdp_proxy/ws_tunnel.py index c622318..41025b1 100644 --- a/app/rdp_proxy/ws_tunnel.py +++ b/app/rdp_proxy/ws_tunnel.py @@ -19,6 +19,7 @@ from app.config import settings from app.db import get_db from app.rbac import user_has_role_for_host from app.recordings.recorder import SessionRecorder +from app.security import active_sessions from app.security.audit import write_audit_event from app.security.crypto import decrypt_secret from app.rdp_proxy.guacd_client import ( @@ -115,6 +116,11 @@ async def rdp_tunnel( details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id}, ) await conn.commit() + logger.debug( + "RDP-Sitzung %s gestartet: user=%s host=%s (%s:%s) client_ip=%s", + session_id, user.username, host["hostname"], host["address"], host["port"], client_ip, + ) + active_sessions.register(session_id, asyncio.current_task()) end_reason = "logout" tunnel = None @@ -125,6 +131,7 @@ async def rdp_tunnel( guacd_host=settings.guacd_host, guacd_port=settings.guacd_port, protocol="rdp", params=params, screen_width=width, screen_height=height, dpi=dpi, ) + logger.debug("RDP-Sitzung %s: guacd-Tunnel zu %s aufgebaut", session_id, host["hostname"]) clipboard_enabled = bool(host.get("clipboard_enabled", True)) tasks = [ asyncio.create_task(_guacd_to_ws(tunnel, websocket, recorder)), @@ -144,13 +151,20 @@ async def rdp_tunnel( except (GuacamoleProtocolError, ConnectionError, OSError) as exc: logger.warning("RDP-Sessionfehler (session_id=%s): %s", session_id, exc) end_reason = "error" + except asyncio.CancelledError: + # Zwangs-Beendigung durch einen Superadmin ueber die Sessionview + # (POST /admin/sessions/{id}/terminate, siehe app/security/active_sessions.py). + end_reason = "terminated_by_admin" + raise finally: + active_sessions.unregister(session_id) del password # Klartext-Passwort so schnell wie moeglich freigeben for task in tasks: task.cancel() if tunnel: await tunnel.close() recorder.close() + logger.debug("RDP-Sitzung %s beendet: reason=%s", session_id, end_reason) await conn.execute( "UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? " "WHERE id = ?", diff --git a/app/security/active_sessions.py b/app/security/active_sessions.py new file mode 100644 index 0000000..f36b732 --- /dev/null +++ b/app/security/active_sessions.py @@ -0,0 +1,46 @@ +""" +In-Memory-Registry der laufenden SSH/RDP-Sitzungen dieses Server-Prozesses. + +Wird von app/ssh_proxy/terminal_ws.py und app/rdp_proxy/ws_tunnel.py beim +Sitzungsstart befuellt (Referenz auf den eigenen asyncio.Task) und beim +Sitzungsende wieder entfernt. Basis fuer die Superadmin-'Sessionview' +(app/admin/routes.py: GET /admin/sessions, POST /admin/sessions/{id}/terminate) +-- 'Beenden' funktioniert per asyncio.Task.cancel(), was den regulaeren +finally-Cleanup-Pfad der jeweiligen WS-Route ausloest (DB-Update, Audit- +Log-Eintrag, WebSocket schliessen), genau wie bei einem normalen Logout. + +Bewusst rein prozesslokal (kein Redis/DB-Backing): bei einem einzelnen +uvicorn-Worker (Standard-Deployment dieses Projekts, siehe ansible/) sieht +und beendet jeder Superadmin-Request alle laufenden Sitzungen. Bei mehreren +Worker-Prozessen sind nur die Sitzungen DIESES Workers 'killable' -- die +Sessionview zeigt das ueber das 'killable'-Feld pro Sitzung an, statt einen +Beenden-Versuch fehlschlagen zu lassen.""" +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + + +@dataclass +class ActiveSession: + session_id: int + task: asyncio.Task + + +_active: dict[int, ActiveSession] = {} + + +def register(session_id: int, task: asyncio.Task) -> None: + _active[session_id] = ActiveSession(session_id=session_id, task=task) + + +def unregister(session_id: int) -> None: + _active.pop(session_id, None) + + +def get(session_id: int) -> ActiveSession | None: + return _active.get(session_id) + + +def all_ids() -> set[int]: + return set(_active.keys()) diff --git a/app/security/log_stream.py b/app/security/log_stream.py new file mode 100644 index 0000000..cfa2214 --- /dev/null +++ b/app/security/log_stream.py @@ -0,0 +1,71 @@ +""" +Ring-Buffer + Pub/Sub fuer das Live-'Verbindungslog' im Admin-Bereich. + +Haengt sich als zusaetzlicher logging.Handler an den Root-Logger, haelt die +letzten LOG-Zeilen im Prozessspeicher (fuer den initialen Replay beim +Oeffnen des Tabs) und verteilt jede neue Zeile an alle aktuell verbundenen +WebSocket-Abonnenten (siehe app/admin/routes.py, WS /admin/ws/logs). + +Bewusst KEINE Persistenz in der DB -- das ist ein reiner Live-Tail (wie +'journalctl -f'), kein durchsuchbares Archiv. Rein prozesslokal: bei mehreren +Worker-Prozessen sieht ein Abonnent nur die Logs des Worker-Prozesses, der +seine WebSocket-Verbindung bedient. +""" +from __future__ import annotations + +import asyncio +import logging +from collections import deque + +MAX_BUFFER_LINES = 1000 +_FORMATTER = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") + +_buffer: deque[str] = deque(maxlen=MAX_BUFFER_LINES) +_subscribers: set[asyncio.Queue[str]] = set() +_installed = False + + +class _BroadcastHandler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + try: + line = _FORMATTER.format(record) + except Exception: + return + _buffer.append(line) + for queue in list(_subscribers): + try: + queue.put_nowait(line) + except asyncio.QueueFull: + # Langsamer/verschwundener Abonnent -- lieber eine Zeile + # verlieren als das Event-Loop-Logging blockieren. + pass + + +def install() -> None: + """Idempotent -- an app.main::lifespan gebunden. Haengt den Handler an + den Root-Logger (sieht alles, was propagiert) und hebt den Level des + eigenen App-Namespace 'jumphost' auf DEBUG an, damit die Verbindungs- + Debug-Meldungen (SSH/RDP-Handshake etc.) ueberhaupt erzeugt werden, ohne + Drittanbieter-Logger (uvicorn, asyncssh) unnoetig auf DEBUG zu stellen.""" + global _installed + if _installed: + return + handler = _BroadcastHandler() + handler.setLevel(logging.DEBUG) + logging.getLogger().addHandler(handler) + logging.getLogger("jumphost").setLevel(logging.DEBUG) + _installed = True + + +def subscribe() -> asyncio.Queue[str]: + queue: asyncio.Queue[str] = asyncio.Queue(maxsize=500) + _subscribers.add(queue) + return queue + + +def unsubscribe(queue: asyncio.Queue[str]) -> None: + _subscribers.discard(queue) + + +def recent_lines() -> list[str]: + return list(_buffer) diff --git a/app/ssh_proxy/proxy.py b/app/ssh_proxy/proxy.py index 3e65d8f..0051f83 100644 --- a/app/ssh_proxy/proxy.py +++ b/app/ssh_proxy/proxy.py @@ -30,6 +30,20 @@ class HostKeyMismatchError(Exception): super().__init__(f"Host-Key-Mismatch: erwartet={expected!r} beobachtet={observed!r}") +class HostKeyDiscoveryError(Exception): + """Wird geworfen, wenn beim Host-Key-Discovery-Versuch (siehe + discover_and_store_host_key) ueberhaupt KEIN Fingerprint beobachtet + werden konnte (TCP/DNS/Timeout-Fehler VOR dem SSH-Key-Exchange) -- im + Unterschied zu einem erwarteten Auth-Fehler NACH dem KEX (siehe dort). + Der admin-only Endpunkt (admin/routes.py) faengt dies ab und liefert + eine saubere 502 statt eines unbehandelten 500.""" + + def __init__(self, host_id: int, reason: str) -> None: + self.host_id = host_id + self.reason = reason + super().__init__(f"Host-Key-Ermittlung fuer Host {host_id} fehlgeschlagen: {reason}") + + class _PinnedHostKeyClient(asyncssh.SSHClient): """Erzwingt Strict Host Key Checking gegen einen fest hinterlegten SHA-256-Fingerprint. Kein automatisches Trust-on-First-Use (TOFU).""" @@ -114,14 +128,45 @@ async def discover_and_store_host_key( """Verbindet EINMALIG ohne Pinning, um den Host-Key-Fingerprint zu erfassen und in der DB zu hinterlegen. Nur ueber einen dedizierten, admin-only Endpunkt aufrufbar -- jeder Aufruf ist eine bewusste Vertrauensentscheidung - und wird im Audit-Log als solche vermerkt (siehe admin/routes.py).""" + und wird im Audit-Log als solche vermerkt (siehe admin/routes.py). + + Bugfix (Fehler 500 bei 'Host-Key ermitteln'): asyncssh.connect() fuehrt + nach dem Key-Exchange (bei dem validate_host_public_key() den Fingerprint + bereits erfasst) automatisch die Authentifizierung durch -- ohne + hinterlegten Client-Key/Passwort schlaegt die IMMER fehl (asyncssh.Error, + z.B. PermissionDenied), obwohl der Fingerprint laengst bekannt ist. Diese + fuer die reine Discovery irrelevante Auth-Fehlermeldung wurde bisher NICHT + abgefangen und riss als unbehandelte Exception bis zu FastAPI durch -> + 500 Internal Server Error bei praktisch jedem Aufruf. Fix: Verbindungs- + fehler abfangen; wenn dabei bereits ein Fingerprint beobachtet wurde, gilt + die Discovery als erfolgreich. Nur wenn wirklich KEIN Fingerprint erfasst + wurde (Host nicht erreichbar, DNS-Fehler, Timeout -- also ein Fehler VOR + dem Key-Exchange), ist es ein echter Fehler (HostKeyDiscoveryError).""" host = await load_host(conn, host_id) + if host["protocol"] != "ssh": + raise HostNotConfiguredError("Host-Key-Ermittlung ist nur fuer SSH-Ziele moeglich") + client = _PinnedHostKeyClient(None, discovery_mode=True) - connection = await asyncssh.connect( - host["address"], port=host["port"], username=host["ssh_username"], - known_hosts=None, client_factory=lambda: client, connect_timeout=10, - ) - connection.close() + connection = None + try: + connection = await asyncssh.connect( + host["address"], port=host["port"], username=host["ssh_username"], + known_hosts=None, client_factory=lambda: client, connect_timeout=10, + ) + except (asyncssh.Error, OSError) as exc: + if client.observed_fingerprint is None: + logger.warning("Host-Key-Ermittlung fuer Host %s fehlgeschlagen: %s", host_id, exc) + raise HostKeyDiscoveryError(host_id, str(exc)) from exc + # Fingerprint wurde bereits waehrend des Key-Exchange erfasst -- der + # anschliessende Auth-Fehler ist fuer die Discovery unschaedlich. + logger.info( + "Host-Key fuer Host %s erfasst (Auth-Phase erwartungsgemaess fehlgeschlagen: %s)", + host_id, exc, + ) + finally: + if connection is not None: + connection.close() + fingerprint = client.observed_fingerprint await conn.execute( "UPDATE hosts SET ssh_host_key_fingerprint = ? WHERE id = ?", (fingerprint, host_id) diff --git a/app/ssh_proxy/terminal_ws.py b/app/ssh_proxy/terminal_ws.py index 19e40fe..e3af077 100644 --- a/app/ssh_proxy/terminal_ws.py +++ b/app/ssh_proxy/terminal_ws.py @@ -21,6 +21,7 @@ from app.auth.deps import get_current_user_ws from app.db import get_db from app.rbac import user_has_role_for_host from app.recordings.recorder import SessionRecorder +from app.security import active_sessions from app.security.audit import write_audit_event from app.ssh_proxy.proxy import HostNotConfiguredError, connect_to_host, load_host @@ -83,6 +84,11 @@ async def ssh_terminal(websocket: WebSocket, host_id: int): details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id}, ) await conn.commit() + logger.debug( + "SSH-Sitzung %s gestartet: user=%s host=%s (%s:%s) client_ip=%s", + session_id, user.username, host["hostname"], host["address"], host["port"], client_ip, + ) + active_sessions.register(session_id, asyncio.current_task()) end_reason = "logout" ssh_conn = None @@ -90,6 +96,7 @@ async def ssh_terminal(websocket: WebSocket, host_id: int): pump_task = None try: ssh_conn = await connect_to_host(conn, host_id) + logger.debug("SSH-Sitzung %s: Verbindung zu %s hergestellt", session_id, host["hostname"]) process = await ssh_conn.create_process(term_type="xterm-256color") pump_task = asyncio.create_task(_pump_ssh_to_ws(process, websocket, recorder)) @@ -120,7 +127,13 @@ async def ssh_terminal(websocket: WebSocket, host_id: int): logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True) except HostNotConfiguredError: end_reason = "error" + except asyncio.CancelledError: + # Zwangs-Beendigung durch einen Superadmin ueber die Sessionview + # (POST /admin/sessions/{id}/terminate, siehe app/security/active_sessions.py). + end_reason = "terminated_by_admin" + raise finally: + active_sessions.unregister(session_id) if pump_task: pump_task.cancel() if process: @@ -128,6 +141,7 @@ async def ssh_terminal(websocket: WebSocket, host_id: int): if ssh_conn: ssh_conn.close() recorder.close() + logger.debug("SSH-Sitzung %s beendet: reason=%s", session_id, end_reason) await conn.execute( "UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? " "WHERE id = ?", diff --git a/static/css/app.css b/static/css/app.css index 74a503e..195293f 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -166,6 +166,11 @@ pre.json-view { background: #0f1218; border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem; font-size: 0.8rem; overflow-x: auto; white-space: pre-wrap; word-break: break-word; } +pre.log-view { + background: #0f1218; border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem; + font-size: 0.78rem; line-height: 1.4; overflow: auto; white-space: pre-wrap; word-break: break-word; + max-height: 60vh; margin-top: 0.5rem; +} .endpoint-row { display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0; cursor: pointer; border-bottom: 1px solid var(--border); diff --git a/static/js/admin.js b/static/js/admin.js index 552f131..fa234a0 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -3,14 +3,8 @@ const ROLE_NAMES = [ "ssh_connect", "rdp_connect", "file_transfer", "clipboard", - "session_recording_view", "admin_hostgroup", + "session_recording_view", "admin_hostgroup", "credentials_view", "credentials_manage", ]; - const AUTH_EVENT_TYPES = new Set([ - "login_password_ok", "login_failed", "login_success", "login_totp_failed", - "login_recovery_code_used", "totp_enroll_started", "totp_enroll_confirmed", - "totp_enroll_failed", "logout", "logout_everywhere", "password_changed", - ]); - const FAILURE_EVENT_TYPES = new Set(["login_failed", "login_totp_failed", "totp_enroll_failed"]); const bannerBox = document.getElementById("banner-box"); let meInfo = { is_admin: false, tenant_admin_of: [] }; @@ -112,15 +106,18 @@ const tabLoaders = { users: loadUsersTab, groups: loadGroupsTab, - hosts: loadHostsTab, + hostgroups: loadHostsTab, + servers: loadHostsTab, credentials: loadCredentialsTab, roles: loadRolesTab, tokens: loadTokensTab, tenants: loadTenantsTab, - authlog: loadAuthLogTab, + sessions: loadSessionsTab, + connlog: loadConnLogTab, audit: loadAuditTab, }; const loadedTabs = new Set(); + let currentTab = null; document.getElementById("tabs").addEventListener("click", (ev) => { const btn = ev.target.closest(".tab-btn"); @@ -130,9 +127,15 @@ document.querySelectorAll(".tab-panel").forEach((panel) => { panel.classList.toggle("hidden", panel.id !== `tab-${tab}`); }); + if (currentTab === "connlog" && tab !== "connlog") disconnectLogStream(); + currentTab = tab; if (!loadedTabs.has(tab)) { loadedTabs.add(tab); tabLoaders[tab]().catch((err) => showBanner(err.message, "error")); + } else if (tab === "connlog") { + connectLogStream(); + } else if (tab === "sessions") { + refreshSessions().catch((err) => showBanner(err.message, "error")); } }); @@ -797,7 +800,7 @@ el("td", { textContent: r.updated_at || "-" }), el("td", {}, [ actionButton("Zum Host", "btn-secondary", async () => { - document.querySelector('.tab-btn[data-tab="hosts"]').click(); + document.querySelector('.tab-btn[data-tab="servers"]').click(); await showHostDetail(r.host_id); }), ...(r.credentials_set @@ -1122,36 +1125,151 @@ }); // --------------------------------------------------------------------- - // Login-Verlauf (Auth-Log) -- clientseitig aus dem Audit-Log gefiltert + // Sessions (nur Super-Admin) -- aktive + historische Sitzungen, Beenden, + // Link zur Aufzeichnung. // --------------------------------------------------------------------- - async function loadAuthLogTab() { - await refreshAuthLog(); + let sessionsActiveOnly = true; + let sessionsAutoTimer = null; + + async function loadSessionsTab() { + document.getElementById("sessions-active-only").checked = sessionsActiveOnly; + await refreshSessions(); + if (sessionsAutoTimer === null) { + sessionsAutoTimer = window.setInterval(() => { + if (currentTab === "sessions") refreshSessions().catch(() => {}); + }, 5000); + } } - async function refreshAuthLog() { - const entries = await getJson("/admin/audit-log?limit=500"); - const authEntries = entries.filter((e) => AUTH_EVENT_TYPES.has(e.event_type)); - const tbody = document.querySelector("#authlog-table tbody"); + document.getElementById("sessions-active-only").addEventListener("change", (ev) => { + sessionsActiveOnly = ev.target.checked; + refreshSessions().catch((err) => showBanner(err.message, "error")); + }); + + async function refreshSessions() { + const rows = await getJson(`/admin/sessions?active_only=${sessionsActiveOnly}&limit=300`); + const tbody = document.querySelector("#sessions-table tbody"); fillTable( tbody, - authEntries.map((e) => - el("tr", {}, [ - el("td", { textContent: e.ts }), - el("td", { textContent: e.user_id === null ? "-" : String(e.user_id) }), - el("td", { textContent: e.client_ip || "-" }), - el("td", { textContent: e.event_type }), + rows.map((s) => { + const actions = el("td", {}); + if (s.is_active && s.killable) { + actions.appendChild( + actionButton("Beenden", "btn-danger", async () => { + await sendJson(`/admin/sessions/${s.id}/terminate`, "POST", {}); + showBanner(`Sitzung #${s.id} beendet.`, "ok"); + await refreshSessions(); + }) + ); + } else if (s.is_active) { + actions.appendChild(el("span", { className: "hint", textContent: "anderer Prozess" })); + } + if (s.has_recording) { + actions.appendChild( + actionButton("Aufzeichnung", "btn-secondary", () => showSessionRecording(s.id)) + ); + } + return el("tr", {}, [ + el("td", { textContent: String(s.id) }), + el("td", { textContent: s.username }), + el("td", { textContent: `${s.hostname} (${s.host_group_name})` }), + el("td", { textContent: s.protocol.toUpperCase() }), + el("td", { textContent: s.started_at }), el("td", {}, [ el("span", { - className: `badge ${FAILURE_EVENT_TYPES.has(e.event_type) ? "danger" : "ok"}`, - textContent: FAILURE_EVENT_TYPES.has(e.event_type) ? "fehlgeschlagen" : "erfolgreich", + className: `badge ${s.is_active ? "ok" : ""}`, + textContent: s.is_active ? "aktiv" : (s.end_reason || "beendet"), }), ]), - ]) - ) + el("td", { textContent: s.client_ip }), + actions, + ]); + }) ); } + async function showSessionRecording(sessionId) { + try { + const result = await getJson(`/admin/sessions/${sessionId}/recording`); + const box = document.getElementById("session-recording-box"); + box.classList.remove("hidden"); + box.className = `banner ${result.verified ? "ok" : "error"}`; + box.textContent = result.verified + ? `Aufzeichnung #${sessionId}: Integritaet OK, ${result.entry_count} Eintraege.` + : `Aufzeichnung #${sessionId}: WARNUNG -- Hash-Kette gebrochen, moeglicherweise manipuliert!`; + } catch (err) { + showBanner(err.message, "error"); + } + } + + // --------------------------------------------------------------------- + // Verbindungslog (nur Super-Admin) -- Live-Tail der Anwendungslogs + // (inkl. Debug fuer SSH/RDP-Verbindungsaufbau) per WebSocket. + // --------------------------------------------------------------------- + + let logSocket = null; + let logLines = []; + const LOG_MAX_LINES = 2000; + + function connectLogStream() { + if (logSocket && (logSocket.readyState === WebSocket.OPEN || logSocket.readyState === WebSocket.CONNECTING)) { + return; + } + const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; + logSocket = new WebSocket(`${proto}//${window.location.host}/admin/ws/logs`); + setConnLogStatus("verbinde..."); + logSocket.addEventListener("open", () => setConnLogStatus("verbunden")); + logSocket.addEventListener("close", () => setConnLogStatus("getrennt")); + logSocket.addEventListener("error", () => setConnLogStatus("Fehler")); + logSocket.addEventListener("message", (ev) => { + try { + const msg = JSON.parse(ev.data); + if (msg.type === "line") appendLogLine(msg.line); + } catch (_err) { + // ungueltige Nachricht ignorieren + } + }); + } + + function disconnectLogStream() { + if (logSocket) { + logSocket.close(); + logSocket = null; + } + setConnLogStatus("getrennt"); + } + + function setConnLogStatus(text) { + const el2 = document.getElementById("connlog-status"); + if (el2) el2.textContent = text; + } + + function appendLogLine(line) { + logLines.push(line); + if (logLines.length > LOG_MAX_LINES) logLines = logLines.slice(-LOG_MAX_LINES); + renderConnLog(); + } + + function renderConnLog() { + const pre = document.getElementById("connlog-output"); + if (!pre) return; + const filter = document.getElementById("connlog-filter").value.trim().toLowerCase(); + const filtered = filter ? logLines.filter((l) => l.toLowerCase().includes(filter)) : logLines; + const wasAtBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 20; + pre.textContent = filtered.join("\n"); + if (wasAtBottom) pre.scrollTop = pre.scrollHeight; + } + + async function loadConnLogTab() { + connectLogStream(); + document.getElementById("connlog-filter").addEventListener("input", renderConnLog); + document.getElementById("connlog-clear-btn").addEventListener("click", () => { + logLines = []; + renderConnLog(); + }); + } + // --------------------------------------------------------------------- // Audit-Log // --------------------------------------------------------------------- @@ -1238,6 +1356,8 @@ : `Mandanten-Admin: ${me.tenant_admin_of.map((t) => t.name).join(", ")}`; document.getElementById("whoami").textContent = `${me.username} (${roleLabel})`; document.getElementById("tenants-tab-btn").classList.toggle("hidden", !me.is_admin); + document.getElementById("sessions-tab-btn").classList.toggle("hidden", !me.is_admin); + document.getElementById("connlog-tab-btn").classList.toggle("hidden", !me.is_admin); loadedTabs.add("users"); await loadUsersTab(); } diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 7246b74..e7b303b 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -10,6 +10,30 @@ return res.json(); } + async function showCredentials(host) { + const box = document.getElementById("credentials-box"); + box.className = "reveal-box"; + box.textContent = `Lade Zugangsdaten fuer ${host.hostname} ...`; + try { + const info = await getJson(`/admin/hosts/${host.id}/credentials`); + const lines = [`Zugangsdaten fuer ${host.hostname}:`]; + lines.push( + info.ssh_keys.length + ? `SSH-Keys: ${info.ssh_keys.map((k) => k.label).join(", ")}` + : "SSH-Keys: keine zugeordnet" + ); + lines.push( + info.rdp_credentials_set + ? `RDP-Passwort gesetzt (zuletzt aktualisiert: ${info.rdp_credentials_updated_at}).` + : "RDP-Passwort: nicht gesetzt." + ); + box.textContent = lines.join("\n"); + } catch (err) { + box.className = "banner error"; + box.textContent = `Zugangsdaten konnten nicht geladen werden: ${err.message}`; + } + } + function hostCard(host) { const div = document.createElement("div"); div.className = "host-card"; @@ -32,6 +56,17 @@ connect.textContent = "Verbinden"; actions.appendChild(connect); + if (host.can_view_credentials) { + const credBtn = document.createElement("button"); + credBtn.type = "button"; + credBtn.textContent = "Zugangsdaten"; + credBtn.addEventListener("click", () => { + document.getElementById("credentials-box").classList.remove("hidden"); + showCredentials(host); + }); + actions.appendChild(credBtn); + } + div.appendChild(actions); return div; } diff --git a/templates/admin.html b/templates/admin.html index 2eb2a24..b93cbe5 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -23,12 +23,14 @@
- + + - + +
@@ -154,8 +156,8 @@ - -