# Jumphost Gateway — Implementierung Umsetzung des Konzepts `Jumphost_Konzept.md` (v0.2): browserbasiertes SSH/RDP-Gateway mit TOTP-Pflicht, Gruppen-/Hostgruppen-RBAC, manipulationssicherem Audit-Log, SQLite/Python-Backend, browserbasierter Admin-Oberflaeche und Ansible-Deployment (mit/ohne nginx, mehrere TLS-Modi). ## Verzeichnisstruktur ``` app/ Backend (FastAPI, Python 3.11+) security/ Crypto, Passwoerter, TOTP, Sessions, Audit-Hash-Chain, AV-Scan, API-Tokens auth/ Login-Flow, RBAC-/Token-Dependencies admin/ Admin-API (User/Gruppen/Hosts/Hostgruppen/Rollen/SSH-Keys/API-Tokens) catalog/ Sicht fuer normale Nutzer (direkt und ueber Gruppen zugewiesene Hosts) ssh_proxy/ SSH-Terminal-WebSocket + SFTP-Filetransfer rdp_proxy/ Guacamole-Protokoll-Tunnel zu guacd recordings/ Hash-verkettete Session-Aufzeichnung db/ SQLite-Migrationen (0001-0007, laufen automatisch beim Start) static/, templates/ Frontend (Vanilla JS, xterm.js, guacamole-common-js, Admin-Oberflaeche, API-Doku) scripts/ Betriebs-/Hilfsskripte (Admin anlegen, Assets bauen) ansible/ Deployment (Rollen, systemd-Unit-Templates) tests/ pytest-Suite ``` ## Lokale Entwicklung / Ausprobieren ```bash python3 -m venv .venv && source .venv/bin/activate pip install -r requirements-dev.txt bash scripts/fetch_frontend_assets.sh # vendored xterm.js / guacamole-common-js export JUMPHOST_ENV=development export JUMPHOST_DATA_DIR=/tmp/jumphost-dev export JUMPHOST_DEV_KEK=$(python3 -c "import secrets;print(secrets.token_hex(32))") export JUMPHOST_DEV_SESSION_SECRET=$(python3 -c "import secrets;print(secrets.token_hex(32))") uvicorn app.main:app --reload --port 8000 python scripts/create_admin.py --username admin # in zweitem Terminal ``` Danach `http://127.0.0.1:8000/` oeffnen, anmelden, TOTP einrichten (QR-Code scannen). Auf dem Dashboard erscheint fuer Admin-Konten oben rechts der Link **"Admin-Bereich"** (`/admin`) — darueber lassen sich Benutzer, Benutzergruppen, Hostgruppen/Hosts/SSH-Keys/RDP-Zugangsdaten, Rollenzuweisungen (direkt und gruppenweise) sowie API-Tokens vollstaendig ueber die Oberflaeche anlegen und verwalten; ein manueller Umweg ueber die rohe JSON-API ist dafuer nicht mehr noetig. Fuer RDP muss zusaetzlich ein laufender `guacd` erreichbar sein (siehe `JUMPHOST_GUACD_HOST`/`_PORT`). Die SQLite-Migrationen 0004 (`user_groups`), 0005 (`api_tokens`), 0006 (`tenants`, seit Migration 0014 wieder vollstaendig zurueckgebaut) und 0007 (`crud_extras`) werden beim naechsten Start automatisch angewendet (`app/db.py`, `_apply_migrations`) — kein manueller Migrationsschritt noetig, auch nicht bei einem bestehenden Datenbestand. ## Admin-Oberflaeche, Gruppen-RBAC, API-Tokens und API-Dokumentation In dieser Session wurde die bis dahin nur per JSON-API bedienbare Verwaltung um eine vollstaendige, CSP-konforme Web-Oberflaeche unter `/admin` erweitert (`templates/admin.html`, `static/js/admin.js`), erreichbar nur fuer Admin- Konten. Tabs: Benutzer, Benutzergruppen, Hosts & Verbindungen (inkl. Connection-Anlage: Hostgruppe, Host, SSH-Key bzw. RDP-Zugangsdaten in einem Formular), Rollen, API-Tokens, Audit-Log. **Gruppen-RBAC (Migration `0004_user_groups.sql`)**: Neu sind *Benutzergruppen* (`user_groups`/`user_group_members`) als Teams von Personen, unabhaengig von den bestehenden *Hostgruppen* (Server-Gruppen). Eine Rolle kann einer Benutzergruppe auf eine Hostgruppe gewaehrt werden (`group_hostgroup_roles`) — das ist das "Sharen einer Verbindung mit einer Gruppe". Es gilt **volle Rollen-Vererbung**: jedes Mitglied einer Gruppe hat automatisch alle Rollen, die dieser Gruppe gewaehrt wurden, zusaetzlich zu seinen individuell zugewiesenen (`user_hostgroup_roles`, unveraendert bestehen geblieben). `app/rbac.py::user_has_role()` prueft dafuer beide Zuweisungswege (UNION-Query); `app/catalog/routes.py` (Host-Liste fuer normale Nutzer) beruecksichtigt beide ebenso. **API-Tokens (Migration `0005_api_tokens.sql`, `app/security/api_tokens.py`)**: Jeder Admin kann unter dem Tab "API-Tokens" persoenliche Tokens erzeugen (`Authorization: Bearer `), mit granularen **Read/Write-Scopes pro Ressource** (z. B. `hosts:ro`, `hosts:rw`, `users:ro`, `groups:rw`, ... vollstaendige Liste unter `GET /admin/scopes`). Tokens werden nur gehasht gespeichert (Klartext ist nur einmal direkt nach dem Anlegen sichtbar) und koennen jederzeit widerrufen werden. `app/auth/deps.py::require_admin_or_scope()` akzeptiert entweder eine gueltige Admin-Session (Cookie, wie bisher) oder einen Token mit passendem Scope — **niemals beides vermischt**: sobald ein `Authorization`-Header vorhanden ist, wird ausschliesslich er geprueft, ein evtl. noch gueltiges Session-Cookie wird dabei ignoriert. Aus Gruenden der Rechte-Eskalation sind die Token-Verwaltungs-Endpunkte selbst (`/admin/tokens/*`) bewusst **nicht** per Token nutzbar, sondern ausschliesslich per Admin-Session (`require_global_admin`) — ein geleaktes Token kann also nie weitere Tokens anlegen, auflisten oder widerrufen. Tokens sind ausschliesslich fuer die Management-API vorgesehen; SSH-/RDP-/SFTP-Sitzungen bleiben Session-Cookie-authentifiziert. **API-Dokumentation (`/docs`, `/openapi.json`)**: Nur fuer eingeloggte Admins sichtbar (`Depends(require_global_admin)` auf beiden Routen in `app/main.py`) — das bestehende Haertungskonzept, `/docs`/`/redoc`/`/openapi` nicht oeffentlich per FastAPI-Default auszuliefern (`docs_url=None, redoc_url=None, openapi_url=None`), bleibt unangetastet. Die Ansicht selbst (`templates/api_docs.html`, `static/js/api-docs.js`) ist bewusst **kein** vendored/CDN-bezogenes Swagger-UI-Bundle, sondern ein selbstgebauter, CSP-konformer Viewer ohne jede Laufzeit-Abhaengigkeit zu Drittanbietern: er liest `/openapi.json` per `fetch()` (`credentials: "same-origin"`) und rendert Endpunkte gruppiert nach Tag, ausschliesslich per `document.createElement`/`textContent` (kein `innerHTML`, keine Inline- Styles/-Scripts, siehe `tests/test_csp_compliance.py`). ## Phase 8: CRUD-Vervollstaendigung, Dateitransfer, Mehrfachrollen (historisch: Mandantenfaehigkeit) Phase 8 fuehrte testweise Mandantenfaehigkeit ein (Migration `0006_tenants.sql`); diese wurde in Teil C des Umsetzungsauftrags vollstaendig zurueckgebaut (Migration `0014_drop_tenants.sql`) — siehe dort fuer die Begruendung. Die CRUD-Vervollstaendigung, der Dateitransfer und die Mehrfachrollen-Vergabe aus dieser Phase bleiben bestehen. Historische `tenant_created`/`tenant_updated`/`tenant_deleted`/`tenant_admin_granted`/ `tenant_admin_revoked`/`tenant_admin_promoted_to_admin`-Audit-Log-Eintraege bleiben bewusst dauerhaft im manipulationssicheren, Hash-verketteten Log erhalten und wurden vom Rueckbau nicht angetastet. Diese Session hat die Admin-Oberflaeche seinerzeit um sechs zusammenhaengende Erweiterungen ergaenzt: **1) CRUD-Vervollstaendigung**: jede Ressource, die die Admin-Oberflaeche anlegen kann, ist jetzt auch bearbeitbar und loeschbar — Benutzer (inkl. Passwort setzen, deaktivieren, reaktivieren), Benutzergruppen, Hostgruppen, Hosts, SSH-Keys (inkl. Rotation). Zwei bewusste Sicherheits-/Integritaets- Entscheidungen dabei: ein Benutzerkonto mit vorhandener Audit-Historie wird **nicht** hart geloescht (die Hash-Chain verweist absichtlich ohne `ON DELETE CASCADE` auf `users.id`, siehe Migration `0007_crud_extras.sql`), sondern deaktiviert und anonymisiert (`deleted_user_`, Passwort/TOTP geloescht); nur ein Konto ganz ohne Audit-Spuren wird tatsaechlich entfernt — die Response (`hard_deleted: true/false`) zeigt an, welcher Fall eintrat. Ein Host wird per Default **soft-deleted** (`is_active=0`, wie schon zuvor vom Katalog beruecksichtigt); `DELETE /admin/hosts/{id}?hard=true` versucht zusaetzlich ein echtes Entfernen, faellt aber automatisch auf Soft-Delete zurueck, falls der Host bereits Sitzungshistorie hat. **2) Login-Verlauf**: neuer Tab "Login-Verlauf" filtert clientseitig aus dem bestehenden Audit-Log-Feed (`GET /admin/audit-log`) gezielt Login-/Logout-/ Fehlversuch-Ereignisse heraus — kein neuer Backend-Endpunkt noetig, da die Rohdaten bereits vorhanden waren, nur bisher nicht dediziert sichtbar. **3) "Details"-Fix fuer Hosts**: der defekte "Details"-Button in der Hosts- Tabelle wurde durch einen neuen Endpunkt `GET /admin/hosts/{id}` (liefert den vollstaendigen, aktuellen Datensatz inkl. zugeordneter SSH-Keys und ob RDP-Zugangsdaten hinterlegt sind) sowie eine neu geschriebene, robuste `showHostDetail()`-Funktion in `admin.js` ersetzt, die bei jedem Aufruf frisch nachlaedt statt sich auf ggf. veraltete Listendaten zu verlassen, und Fehler inline anzeigt statt sie zu verschlucken. Da diese Sandbox die App nicht tatsaechlich im Browser ausfuehren kann, war die urspruengliche Ursache nicht direkt reproduzierbar — bitte nach dem Update pruefen, ob der Button jetzt zuverlaessig funktioniert, oder bei einem verbleibenden Fehler die genaue Browser-Konsolenmeldung mitteilen. **4) Eigener "Zugangsdaten"-Tab**: buendelt SSH-Keys UND RDP/Windows- Passwoerter (bisher Teil des Hosts-Formulars) an einer Stelle, inkl. Uebersicht, welche RDP-Hosts bereits ein Passwort hinterlegt haben (`GET /admin/rdp-credentials`). **5) Dateitransfer-Fenster (SSH-Terminal)**: der bisherige, `prompt()`- basierte Einzel-Upload-Knopf in der Terminal-Sitzung (`templates/terminal.html`, `static/js/terminal.js`) wurde durch ein eigenstaendiges Dateitransfer-Panel ersetzt, das **beide Richtungen** abdeckt — Upload (wie zuvor, jetzt mit eigenem Formular statt Browser-`prompt()`) und **Download** (neu in der UI; der Backend-Endpunkt `GET /ssh/{host_id}/files/download` existierte bereits und wird per `fetch()` + Blob + synthetischem ``-Link angesteuert, damit Fehler inline im Panel erscheinen statt die Seite zu verlassen). Das Panel fuehrt zusaetzlich ein kurzes Transfer-Log der laufenden Sitzung. Gilt bewusst nur fuer SSH (RDP hat in dieser Implementierung keinen eigenen Dateitransfer-Endpunkt). **6) Mehrfachauswahl bei Rollenvergabe**: `POST /admin/roles/grant` und `POST /admin/group-roles/grant` akzeptieren jetzt `role_names` (Liste, 1-6 Rollen) statt einer einzelnen `role_name` — ein Benutzer bzw. eine Benutzergruppe kann damit in einem Schritt mehrere Rollen auf derselben Hostgruppe erhalten (Checkbox-Raster statt Dropdown in der Oberflaeche). 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 /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 technische Details zum Verbindungsaufbau aller Hosts preisgeben koennen. Der Endpunkt liegt bewusst als eigener Top-Level-Router unter `/ws/logs` (siehe `app/admin/log_ws.py`) statt unter `/admin/...` — der nginx-Reverse-Proxy setzt die fuer WebSockets noetigen Upgrade-Header nur fuer die `location /ws/ { ... }` (siehe `ansible/roles/nginx_proxy/templates/jumphost.conf.j2`); ein Pfad unter `/admin/ws/...` wuerde auf die generische `location /` fallen und der Handshake wuerde hinter dem Proxy fehlschlagen (Status bliebe dauerhaft "getrennt", ohne dass ein Fehler in der Anwendung selbst sichtbar wird). **4) Sessionview-Dashboard (nur Super-Admin)**: neuer Tab "Sessions" + `GET /admin/sessions` (aktive + historische Sitzungen), `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 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). ## Phase 10: SSH-Key-Passphrasen und funktionsfaehige RDP-Sitzungen Behebt die Ursachen dafuer, dass weder SSH- noch RDP-Sitzungen zustande kamen. **SSH -- passphrasegeschuetzte Schluessel (Migration 0009, `ssh_keys.passphrase_enc`).** `asyncssh.import_private_key()` wurde ohne Passphrase aufgerufen; ein mit `ssh-keygen` ueblich erzeugter, verschluesselter Schluessel liess sich damit prinzipiell nicht laden (`KeyImportError: Passphrase must be specified to import encrypted private keys`). Weil dieser Fehler ein `ValueError` und *kein* `asyncssh.Error` ist, lief er an allen Fehlerbehandlungen der WebSocket-Route vorbei -- der Browser sah nur ein wortloses Verbindungsende. Neu: * Beim Anlegen und Rotieren eines Schluessels kann eine Passphrase angegeben werden. Sie wird wie das Schluesselmaterial selbst mit dem KEK (AES-256-GCM) verschluesselt gespeichert und ueber keinen Endpunkt jemals zurueckgegeben -- die Liste zeigt nur `has_passphrase`. * Zu einem **bereits hinterlegten** Schluessel laesst sich die Passphrase nachtragen (Adminbereich -> Zugangsdaten -> SSH-Key bearbeiten). Sie wird dabei sofort gegen das gespeicherte Schluesselmaterial geprueft; passt sie nicht, wird nichts gespeichert. * Jeder Upload wird sofort gegen asyncssh validiert: ein unbrauchbarer Schluessel wird mit HTTP 400 und Klartextbegruendung abgelehnt, statt erst beim ersten Verbindungsversuch eines Benutzers aufzufallen. * Scheitert das Laden trotzdem, meldet die Sitzung jetzt `PrivateKeyUnusableError` mit verstaendlichem Text an Terminal und Dateitransfer statt kommentarlos abzubrechen. **RDP -- vier unabhaengige Fehler, die jede Sitzung verhinderten.** 1. `static/js/rdp.js` haengte die Verbindungsparameter an die Tunnel-URL an -- `Guacamole.WebSocketTunnel.connect(data)` haengt aber selbst noch `"?" + data` an. Daraus wurde `...&dpi=96?undefined`; FastAPI wies den WebSocket wegen der ungueltigen Query noch vor dem Routenhandler ab, und im Verbindungslog erschien **kein einziger Eintrag**. Die Parameter werden jetzt an `connect()` uebergeben. 2. guacamole-common-js oeffnet den Socket immer mit dem Subprotokoll `guacamole`. Der Server bestaetigte es nicht, woraufhin der Browser die Verbindung nach RFC 6455 sofort wieder verwarf. `websocket.accept()` setzt es jetzt. 3. `load_host()` selektierte `rdp_username`, `rdp_domain`, `rdp_require_nla` und `clipboard_enabled` nicht -- `build_rdp_params()` baute daraus eine `connect`-Instruktion **ohne Benutzernamen** (Anmeldung am Ziel scheitert, der Client haengt in "Warte auf Server ..."), und die Zwischenablage war unabhaengig von der Hostkonfiguration immer gesperrt. Fehlt jetzt eine der Angaben, meldet der Server das im Klartext statt still eine kaputte Verbindung aufzubauen. 4. `ignore-cert` war hart auf `false` verdrahtet. Windows-Ziele ohne eigene PKI weisen sich mit einem selbstsignierten Zertifikat aus, guacd/FreeRDP bricht dann vor dem ersten Bild ab. Neu: `hosts.rdp_ignore_cert` (Migration 0009, Default: ignorieren) mit Checkbox im Host-Formular. Ausserdem im Guacamole-Protokoll behoben: die Laengenangaben zaehlen **Zeichen**, nicht Bytes (so ist das Protokoll definiert, und so zaehlen guacd und guacamole-common-js). Zuvor stand dort die UTF-8-Bytelaenge -- ein einziger Umlaut, etwa in einem RDP-Passwort oder in der Zwischenablage, verschob den gesamten nachfolgenden Datenstrom. Tunnelinterne Instruktionen (leerer Opcode: `ping`, Tunnel-UUID) werden nicht mehr an guacd durchgereicht, ein `ping` wird gespiegelt, und der Server sendet die Tunnel-UUID als erste Instruktion -- sonst blieb der Client bis zum ersten Bild in "Warte auf Server ..." und lief bei einem langsamen RDP-Handshake in den 15-Sekunden-Timeout. **Sichtbarkeit.** Saemtliche Abbruchpfade *vor* dem eigentlichen Sitzungsbeginn (fehlende Berechtigung, unbekannter Host, falsches Protokoll, fehlendes RDP-Passwort) schlossen den Socket bisher kommentarlos. Sie protokollieren jetzt und geben den Grund als WebSocket-Close-Reason mit, den `rdp.js` direkt anzeigt. ### Nach dem Deployment zu tun 1. Migration 0009 laeuft beim Start automatisch. 2. Zu jedem bereits hinterlegten, passphrasegeschuetzten SSH-Key die Passphrase nachtragen (Adminbereich -> Zugangsdaten -> SSH-Key bearbeiten). 3. Bei RDP-Hosts pruefen, dass ein RDP-Benutzername gesetzt ist. ## Phase 11: fehlendes `bcrypt` im Virtualenv Symptom: `Der private Schluessel konnte nicht gelesen werden: OpenSSH private key encryption requires bcrypt with KDF support`. Ursache war weder der Schluessel noch die (seit Phase 10 speicherbare) Passphrase, sondern die **Serverumgebung**: OpenSSH verschluesselt private Schluessel mit `bcrypt_pbkdf`. asyncssh und `cryptography` fuehren diese Ableitung nur aus, wenn das Python-Modul `bcrypt` installiert ist -- es ist bei beiden ein *optionales* Extra (`asyncssh[bcrypt]`) und stand nicht in `requirements.txt`. Im ausgerollten Virtualenv fehlte es deshalb, und damit war jeder mit `ssh-keygen` erzeugte passphrasegeschuetzte Schluessel prinzipiell unlesbar -- unabhaengig davon, ob die Passphrase stimmte. Behoben: * `bcrypt` steht jetzt als **Pflichtabhaengigkeit** in `requirements.txt` (gepinnt wie alle anderen; entspricht dem Extra `asyncssh[bcrypt]`). * `import_private_key_material()` uebersetzt die englische Bibliotheksmeldung in einen deutschen Klartext, der die Ursache benennt (Serverumgebung, nicht Schluessel/Passphrase) und den Behebungsweg nennt. Diese Meldung erscheint im Terminal, im Dateitransfer und als HTTP 400 beim Key-Upload. * Der Anwendungsstart prueft die Abhaengigkeit einmalig und schreibt eine ERROR-Zeile ins Log (und damit ins Live-Verbindungslog des Adminbereichs), statt den Fehler bis zur ersten Benutzersitzung zu verschleppen. ### Nach dem Deployment zu tun ```bash # Ansible: die Rolle python_runtime installiert requirements.txt neu ansible-playbook -i inventory/production.ini site.yml # oder von Hand auf dem Jumphost: sudo -u jumphost /opt/jumphost/venv/bin/pip install -r /opt/jumphost/requirements.txt sudo systemctl restart jumphost-app ``` Kontrolle: `journalctl -u jumphost-app | grep bcrypt` bleibt nach dem Neustart leer. Erscheint die ERROR-Zeile, laeuft der Dienst noch gegen ein Virtualenv ohne `bcrypt`. ## Phase 12: Benutzername gehoert zu den Zugangsdaten -- und wirksames Host-Key-Pinning **Symptom:** `Host-Key konnte nicht ermittelt werden -- Ziel nicht erreichbar: Permission denied for user l4u on host `. Das Ziel war sehr wohl erreichbar: der Key-Exchange war laengst durch, nur die anschliessende Anmeldung scheiterte. Die Ermittlung lief bisher ueber einen vollstaendigen `asyncssh.connect()`-Versuch und las den Fingerprint aus dem Callback `SSHClient.validate_host_public_key()`. **Diesen Callback ruft asyncssh nur auf, wenn `known_hosts` nicht `None` ist** -- mit `known_hosts=None` ist die Host-Key-Pruefung komplett abgeschaltet. Der Fingerprint blieb deshalb leer, und der voellig erwartbare Auth-Fehler wurde als "Ziel nicht erreichbar" gemeldet. **Dieselbe Ursache hatte eine sicherheitsrelevante zweite Wirkung:** der regulaere Verbindungspfad benutzte exakt dieselbe Kombination. Das "Strict Host Key Checking" gegen den gepinnten Fingerprint lief also faktisch nie -- der Server wurde ueberhaupt nicht geprueft. Neu: * Die Ermittlung benutzt `asyncssh.get_server_host_key()`: reiner Key-Exchange, gar kein Anmeldeversuch. Ein Auth-Fehler kann sie damit nicht mehr verhindern. Gespeichert wird jetzt neben dem Fingerprint auch der **vollstaendige Host-Key** (`hosts.ssh_host_key`, Migration 0010). * Vor jeder SSH-Sitzung wird der Host-Key des Ziels auf demselben Weg geholt und gegen den hinterlegten geprueft -- **bevor** Schluesselmaterial entschluesselt oder eine Anmeldung versucht wird. Weicht er ab, bricht die Sitzung mit Klartextmeldung ab. Nach dem Sitzungsaufbau wird der Schluessel der Sitzung noch einmal gegengeprueft (`connection.get_server_host_key()`), damit auch ein Wechsel zwischen Pruefung und Sitzung auffaellt. * Ist fuer einen Host **kein** Host-Key hinterlegt, wird die Verbindung abgelehnt (`HostKeyNotPinnedError`) statt stillschweigend jedem Gegenueber zu vertrauen. Kein Trust-on-First-Use. * Fuer Hosts, deren Key vor Migration 0010 ermittelt wurde, wird der vollstaendige Schluessel beim naechsten Verbindungsaufbau nachgetragen -- nachdem er gegen den gepinnten Fingerprint geprueft wurde. **Benutzername gehoert zur Anmeldung, nicht zum Server (Migration 0010).** `hosts.ssh_username` / `hosts.rdp_username` / `hosts.rdp_domain` waren am falschen Objekt: der Anmeldename ist Teil der Zugangsdaten. Ab jetzt: * **SSH:** `ssh_keys.username` -- ein Benutzername je Schluessel ("dieser Key ist der Zugang von l4u"). Fuer ein zweites Konto wird ein zweiter Schluesseleintrag angelegt. * **RDP/Windows:** `rdp_credentials.username` und `.domain` -- zusammen mit dem Passwort, das ohnehin schon dort liegt. Der Benutzername ist beim Setzen der Zugangsdaten Pflicht. * Die Felder verschwinden aus dem Host-Formular; das Hostdetail zeigt stattdessen an, mit welchem Namen die Anmeldung erfolgen wuerde. * Die Migration uebernimmt die alten Werte: bei RDP eindeutig, bei SSH nur dann, wenn alle Hosts eines Schluessels denselben Benutzernamen hatten -- sonst bliebe es Raten. Solche Schluessel bleiben leer und greifen weiter auf den Host-Wert zurueck, bis ein Admin den Namen am Schluessel setzt (die Oberflaeche weist darauf hin). * Die alten Spalten bleiben als Fallback lesbar (ein Spalten-Drop erzwaenge in SQLite einen Tabellen-Rebuild, siehe Begruendung in `0014_drop_tenants.sql`), die API nimmt sie als `deprecated` weiterhin entgegen. ### Nach dem Deployment zu tun 1. Migration 0010 laeuft beim Start automatisch. 2. Fuer jeden SSH-Key pruefen, ob der Benutzername gesetzt ist (Adminbereich -> Zugangsdaten -> Spalte "Benutzer"). 3. Bei RDP-Hosts die Zugangsdaten einmal neu speichern (Benutzername + Domaene + Passwort), falls die Uebernahme nichts gefunden hat. 4. Fuer SSH-Hosts ohne hinterlegten Host-Key einmal "Host-Key ermitteln" ausfuehren -- ohne ihn wird die Verbindung jetzt abgelehnt. ## Tests ```bash pytest -q ``` 105 Tests (vorher 88; `tests/test_phase12.py` kam hinzu) 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 dedizierte Security-/Pentest-Tests (`tests/test_pentest_security.py`), 5 CSP-Regressionstests (`tests/test_csp_compliance.py`, inkl. `/admin` und `/docs`) sowie 14 neue Tests zu Gruppen-RBAC-Vererbung, Admin-Oberflaeche und API-Tokens (`tests/test_admin_groups_tokens.py`): Rollen-Vererbung ueber Gruppenmitgliedschaft, dass Gruppenrollen NICHT auf Nicht-Mitglieder wirken, Admin-only-Durchsetzung fuer alle neuen Endpunkte, Token-Scope-Durchsetzung (read/write/implizites read-bei-write/widerrufen/abgelaufen/unbekannter Scope), dass ein Token niemals andere Tokens verwalten kann (Rechte- Eskalationsschutz), sowie Admin-Gating von `/docs` und `/openapi.json`. Details, Vorgehen und Ergebnisse der urspruenglichen 27 Tests: siehe `Pentest_Report.md`. 9 der urspruenglich zu Phase 8 hinzugekommenen Tests (`tests/test_admin_crud.py`, Nachfolger von `tests/test_tenants.py` — die 6 reinen Tenant-CRUD-/-Isolationstests entfielen mit dem Rueckbau in Teil C) decken weiterhin ab: Mehrfachauswahl bei Einzel- UND Gruppen-Rollenvergabe, Update/Deactivate/Delete fuer Benutzer (inkl. Anonymisieren-statt-Hart- 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 `GET /admin/hosts/{id}`-Detailendpunkt inkl. SSH-Key-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 (Nicht-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:** Von den 41 in dieser und den beiden vorherigen Sessions neu > hinzugekommenen Tests (5 CSP + 14 Admin/Gruppen/Token + 15 Tenant-CRUD/ > -Isolation + 7 Phase 9) sind nach dem Mandanten-Rueckbau in Teil C nur noch > 35 relevant (9 CRUD-/Rollen-Tests aus den urspruenglichen 15 blieben als > `tests/test_admin_crud.py` erhalten, 6 reine Tenant-Tests entfielen). Sie > konnten in der damals 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 > synchrones `sqlite3`-basiertes Shim mit aiosqlite-kompatiblem > `async`-Interface) erlauben es, die **echten** Endpunkt-Funktionen aus > `app/admin/routes.py` direkt (ohne HTTP-Layer) gegen eine echte > In-Memory-`sqlite3`-Datenbank mit allen 7 Migrationen auszufuehren — damit > wurden ueber 20 Integrations-Assertions (u.a. Tenant-Isolation — seither > mit Migration 0014 wieder entfernt, siehe oben —, Rollen-Mehrfachvergabe, > Delete-Semantik) tatsaechlich lauffaehig verifiziert, nicht nur simulierte > SQL-Queries. Der damalige `tests/test_tenants.py` (heute Nachfolger > `tests/test_admin_crud.py`) selbst nutzte 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. 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 Login+TOTP-Flow per curl, Admin-CRUD (Hostgruppe/Host anlegen), Audit-Log- Chain-Verifikation per `/admin/audit-log/verify`. ## Security-Tests & Haertungs-Nachweis - **SAST**: `bandit -r app -c .bandit.yml` sowie `pip-audit` (Dependency-CVE- Scan) liefen zu Beginn dieser Implementierung sauber durch (0 Findings, 0 bekannte Schwachstellen) — Details inkl. vorher/nachher-Tabellen und der dabei gefundenen und gefixten Starlette-Regression in `Pentest_Report.md`. Fuer die in dieser Session neu hinzugekommenen Dateien (Admin-Oberflaeche, Gruppen-RBAC, API-Tokens) steht ein erneuter `bandit`/`pip-audit`-Lauf noch aus (keine neuen Fremdabhaengigkeiten hinzugekommen). - **DAST**: `tests/test_pentest_security.py` (17 Tests) sowie `tests/test_admin_groups_tokens.py` (14 Tests, s.o.) simulieren konkrete Angriffsmuster gegen die laufende ASGI-App. - **OS-Haertung (CIS/STIG)**: die `ansible/roles/os_hardening`-Rolle wurde in einer frueheren Session um ~10 zusaetzliche Task-Dateien vertieft (Kernel-Module, sysctl, PAM/Passwort-Policy, erweiterte auditd-Regeln, AIDE, rkhunter, Banner, cron/at-Restriktion, Dateirechte/sudo-Logging, SSHD-Haertung). Vollstaendiges Mapping auf CIS-Controls inkl. bewusst nicht automatisierter Punkte (mit Begruendung, z.B. Partitionslayout, Bootloader-Passwort, Volltextverschluesselung, physische Sicherheit) und bekannter Einschraenkungen: `ansible/roles/os_hardening/CIS_STIG_MAPPING.md`. - Abhaengigkeiten sind in `requirements.txt`/`requirements-dev.txt` exakt auf gegen `pip-audit` gepruefte Versionen gepinnt (u.a. fastapi 0.141.1, starlette 1.6.0, cryptography 50.0.0) — bewusste Reproduzierbarkeits-/ Haertungsmassnahme, siehe `Pentest_Report.md` Abschnitt 2. ## Deployment ```bash cd ansible cp inventory/production.ini.example inventory/production.ini # anpassen cp inventory/group_vars/jumphost_with_nginx.yml.example inventory/group_vars/jumphost_with_nginx.yml ansible-vault encrypt inventory/group_vars/vault.yml # vorher aus vault.yml.example befuellen ansible-playbook -i inventory/production.ini site.yml --ask-vault-pass ``` `enable_nginx_proxy` und `tls_mode` (`internal_pki` / `external_reverse_proxy` / `acme_public`) steuern Proxy- und TLS-Verhalten, siehe Konzept Kap. 7.2/7.2a und `ansible/inventory/group_vars/all.yml`. `ansible-playbook site.yml --syntax-check` laeuft sauber durch (in einer frueheren Session verifiziert); ein voller `--check`-Lauf gegen eine echte Testumgebung (inkl. `apt`, `systemd`, `guacd`-Paketverfuegbarkeit auf der Zieldistribution) steht noch aus. Ein Neustart des Dienstes (`systemctl restart jumphost`) reicht aus, damit die Migrationen 0004/0005 angewendet werden. ## Was bewusst noch offen ist Diese Implementierung ist ein funktionsfaehiges, getestetes und in einer frueheren Session per SAST+DAST geprueftes Grundgeruest, aber weiterhin **kein fertig auditiertes Produktivsystem**. Der vollstaendige Befund inkl. Methodik, Vorgehen und Restrisikobewertung steht in `Pentest_Report.md` — die dortige Abschnitt-4-Tabelle ("Restrisiko / vor Produktivbetrieb noch zu tun") ist die massgebliche, aktuelle Fassung dieser Liste. Kurzfassung: 1. **Echter Netzwerk-Penetrationstest** gegen eine laufende Instanz (Portscan, TLS-Konfiguration live, Session-Isolation unter Last, Guacamole-Protokoll-Fuzzing) — in dieser Sandbox ohne Netzwerkzugriff auf ein reales Zielsystem nicht durchfuehrbar. Was stattdessen gemacht wurde: SAST (bandit, pip-audit) und ein DAST-Testlauf gegen die App im Prozess (17+14 Security-/Admin-Tests, siehe `Pentest_Report.md` bzw. oben). 2. **RDP/guacd-Integrationstest gegen echte Zielsysteme** — die Guacamole-Protokoll-Implementierung (`app/rdp_proxy/guacd_client.py`) wurde gegen die Protokollspezifikation implementiert und die Handshake-Logik lokal auf Korrektheit der Kodierung geprueft, aber NICHT gegen einen laufenden `guacd` + FreeRDP + Windows-Ziel end-to-end getestet (keine RDP-Zielumgebung in dieser Sitzung verfuegbar). Parameter-Namen/-Reihenfolge sollten gegen die tatsaechlich eingesetzte guacd-Version verifiziert werden. 3. **Ansible-Rollout gegen ein reales Zielsystem** (`--check`-Dry-Run und echter Rollout in einer Staging-Umgebung) — bisher nur `ansible-playbook site.yml --syntax-check` sowie YAML-/Jinja2-Parsing verifiziert, siehe `ansible/roles/os_hardening/CIS_STIG_MAPPING.md`. 4. **OpenSCAP-Compliance-Scan** (`oscap xccdf eval`) gegen das zutreffende CIS/STIG-Profil — die `os_hardening`-Rolle wurde in einer frueheren Session um ca. 10 Task-Dateien vertieft (siehe CIS_STIG_MAPPING.md fuer das vollstaendige Mapping inkl. bewusst nicht automatisierter Punkte), ersetzt aber keinen zertifizierten Benchmark-Scan. 5. **Verteiltes Rate-Limiting** — der aktuelle Login-Rate-Limiter ist In-Memory/Single-Process (siehe `app/security/rate_limit.py`); bei horizontaler Skalierung durch einen geteilten Store ersetzen. 6. **fail2ban-Filter** setzt strukturierte Access-Logs mit `client_ip=`-Feld voraus, die die App aktuell nicht schreibt — vor Produktivbetrieb ein Access-Log-Middleware ergaenzen oder auf das Audit-Log umstellen. 7. **CSRF**: SameSite=Strict-Cookies mindern das Risiko bereits deutlich; ein expliziter CSRF-Token fuer zustandsaendernde JSON-Requests ist als zusaetzliche Haertungsstufe vorgesehen, aber noch nicht implementiert. 8. Alle in Konzept Kap. 12 genannten Erweiterungen (LDAP/AD, WebAuthn, PostgreSQL-Migrationspfad, Just-in-Time-Zugriff, HA) sind noch nicht umgesetzt. 9. **Social Engineering / physische Sicherheit / Lastest (DoS)** wurden nicht getestet — ausserhalb des Scopes eines Code-/Konfigurations-Reviews in dieser Sandbox-Umgebung. 10. **API-Token-Rotation/-Ablaufrichtlinien**: Tokens koennen zwar mit optionalem Ablaufdatum erzeugt und jederzeit widerrufen werden, aber es gibt noch keine erzwungene maximale Laufzeit, keine automatische Benachrichtigung vor Ablauf und keine "Token zuletzt benutzt vor X Tagen -> automatisch deaktivieren"-Routine. 11. Ein erneuter `bandit`/`pip-audit`-Lauf ueber die in dieser und der Phase-8-Session neu hinzugekommenen Dateien (Admin-Oberflaeche, Gruppen-RBAC, API-Tokens, Dateitransfer-Panel) steht noch aus (keine neuen Fremdabhaengigkeiten hinzugekommen, siehe oben). 12. **Phase 8 / "Details"-Fix**: der Root-Cause des urspruenglich gemeldeten defekten "Details"-Buttons konnte in dieser Sandbox nicht reproduziert werden (die App laesst sich hier nicht im Browser ausfuehren) — der neue `GET /admin/hosts/{id}`-Endpunkt plus robustere `showHostDetail()`-Logik sollten das Problem loesen, sollten aber nach dem Deployment einmal manuell im Browser bestaetigt werden. ## Phase 14: `get_server_host_key()` kennt kein `connect_timeout` **Meldung:** `Host-Key konnte nicht ermittelt werden -- Ziel nicht erreichbar: get_server_host_key() got an unexpected keyword argument 'connect_timeout'`, und zwar in **unter einer Sekunde** nach dem Klick auf "Host-Key ermitteln". **Ursache:** `asyncssh.get_server_host_key()` nimmt -- anders als `asyncssh.connect()` -- **kein `**kwargs`** entgegen. Seine Parameterliste ist in asyncssh 2.18.0 abschliessend (`host, port, tunnel, proxy_command, family, flags, local_addr, sock, client_version, kex_algs, server_host_key_algs, config, options`); `connect_timeout` gehoert **nicht** dazu. Nur `asyncssh.connect()` reicht unbekannte Argumente ueber `**kwargs` an `SSHClientConnectionOptions` weiter -- deshalb war `connect_timeout=10` dort korrekt und hier ein sofortiger `TypeError`, noch bevor ein Socket geoeffnet wurde. Daher die Reaktionszeit von unter einer Sekunde: es gab nie einen Verbindungsversuch. Der Fehler stammt aus Phase 12 und betraf **beide** Aufrufstellen in `app/ssh_proxy/proxy.py`: `discover_and_store_host_key()` (Knopf "Host-Key ermitteln") und `_verified_host_key()` -- letzteres liegt im regulaeren Verbindungspfad, es war also **jede** SSH-Sitzung betroffen, nicht nur die Ermittlung. **Warum es dreimal falsch diagnostiziert wurde:** anfangs lief der `TypeError` unbehandelt bis FastAPI -> nackter HTTP 500 ohne Wortlaut. Der Nachtrag zu Phase 12 deutete diesen 500er als zu enges `except (asyncssh.Error, OSError)` und verbreiterte auf `except Exception`. Damit wurde der `TypeError` zwar gefangen -- aber als `HostKeyDiscoveryError` und damit als "Ziel nicht erreichbar" **verkleidet**. Die Tests aus Phase 12 konnten es nicht sehen, weil ihre asyncssh-Attrappen als `(host, port=22, **kw)` definiert waren und jedes beliebige Argument klaglos schluckten. **Loesung** * Neuer Helfer `_fetch_server_host_key(address, port)` in `proxy.py`: ruft `asyncssh.get_server_host_key(address, port=port)` auf und setzt das Zeitlimit von aussen ueber `asyncio.wait_for()` (`HOST_KEY_CONNECT_TIMEOUT = 10`). Damit ist der Aufruf unabhaengig davon, wie einzelne asyncssh-Versionen ihre Optionen benennen. * Beide Aufrufstellen benutzen den Helfer. Ein `asyncio.TimeoutError` wird als `HostKeyDiscoveryError` mit Klartext "Zeitlimit von 10s ueberschritten" gemeldet. * **Ein `TypeError` wird ausdruecklich NICHT mehr maskiert**, sondern durchgereicht (und mit vollem Traceback geloggt): ein Aufruffehler im eigenen Code darf nicht als Netzwerkproblem erscheinen. Genau diese Maskierung hat die Fehlersuche zweimal in die falsche Richtung geschickt. Alle uebrigen Fehler dieses einen externen Aufrufs bleiben wie bisher breit gefangen. * `tests/test_phase12.py`: die Attrappen haben jetzt **kein** `**kw` mehr. * `tests/test_phase14.py` (8 Faelle), u.a. ein Test, der per `inspect.signature(asyncssh.get_server_host_key).bind(...)` gegen das **tatsaechlich installierte** asyncssh prueft -- der haette den Bug im Produktivsystem gefunden.