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
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) 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 <token>), 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: Mandantenfaehigkeit, CRUD-Vervollstaendigung, Dateitransfer, Mehrfachrollen
Diese Session hat die Admin-Oberflaeche um sieben zusammenhaengende Erweiterungen ergaenzt:
1) Mandantenfaehigkeit ("volle Isolation + Mandanten-Admins", Migration
0006_tenants.sql): jede Hostgruppe, Benutzergruppe, jeder SSH-Key und
jedes API-Token gehoert zu genau einem Mandanten (tenants-Tabelle,
Standard-Mandant id=1 "Standard" fuer bestehende Daten). Es gibt zwei
Admin-Stufen: Super-Admin (users.is_admin=1, unveraendert wie zuvor)
sieht/verwaltet alle Mandanten weiterhin vollstaendig — keine Regression
gegenueber Phase 7. Mandanten-Admin (neue tenant_admins-Zuordnung,
ein User kann Admin mehrerer Mandanten sein) sieht/verwaltet ausschliesslich
die Ressourcen seines/seiner Mandanten; jeder Zugriffsversuch auf eine
fremde Mandanten-ID liefert bewusst 404 (nicht 403, siehe
app/tenancy.py, TenantScope) — ein Mandanten-Admin soll aus der
Fehlerantwort nicht einmal ableiten koennen, dass eine ID ausserhalb seines
Mandanten ueberhaupt existiert. Tenant-CRUD und die Ernennung von
Mandanten-Admins sind ausschliesslich Super-Admin-Aktionen (neuer Tab
"Mandanten" in der Admin-Oberflaeche, fuer Mandanten-Admins nicht sichtbar).
Ein API-Token ist immer an genau einen Mandanten gebunden
(api_tokens.tenant_id) und kann diesen nie verlassen.
2) 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_<id>, 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.
3) 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.
4) "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.
5) 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).
6) 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 <a download>-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).
7) 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 mandantenuebergreifend technische Details 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 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).
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
PrivateKeyUnusableErrormit verstaendlichem Text an Terminal und Dateitransfer statt kommentarlos abzubrechen.
RDP -- vier unabhaengige Fehler, die jede Sitzung verhinderten.
static/js/rdp.jshaengte die Verbindungsparameter an die Tunnel-URL an --Guacamole.WebSocketTunnel.connect(data)haengt aber selbst noch"?" + dataan. 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 anconnect()uebergeben.- 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. load_host()selektierterdp_username,rdp_domain,rdp_require_nlaundclipboard_enablednicht --build_rdp_params()baute daraus eineconnect-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.ignore-certwar hart auffalseverdrahtet. 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
- Migration 0009 laeuft beim Start automatisch.
- Zu jedem bereits hinterlegten, passphrasegeschuetzten SSH-Key die Passphrase nachtragen (Adminbereich -> Zugangsdaten -> SSH-Key bearbeiten).
- 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:
bcryptsteht jetzt als Pflichtabhaengigkeit inrequirements.txt(gepinnt wie alle anderen; entspricht dem Extraasyncssh[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
# 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 <ip>.
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.usernameund.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
0006_tenants.sql), die API nimmt sie alsdeprecatedweiterhin entgegen.
Nach dem Deployment zu tun
- Migration 0010 laeuft beim Start automatisch.
- Fuer jeden SSH-Key pruefen, ob der Benutzername gesetzt ist (Adminbereich -> Zugangsdaten -> Spalte "Benutzer").
- Bei RDP-Hosts die Zugangsdaten einmal neu speichern (Benutzername + Domaene + Passwort), falls die Uebernahme nichts gefunden hat.
- Fuer SSH-Hosts ohne hinterlegten Host-Key einmal "Host-Key ermitteln" ausfuehren -- ohne ihn wird die Verbindung jetzt abgelehnt.
Tests
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. 15 weitere neue Tests zu Phase 8
(tests/test_tenants.py) decken zusaetzlich ab: Tenant-CRUD (Super-Admin
only, Mandanten-Admin explizit ausgeschlossen), volle Mandanten-Isolation
(Hostgruppen/Hosts/Benutzer/SSH-Keys/Audit-Log — fremde Mandanten-IDs
liefern 404), 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 neuen 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 (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 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 -qausgefuehrt werden, da diese Sandbox keinen Netzwerkzugriff auf PyPI hat undfastapi/aiosqlite/argon2/asyncsshdort 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 (fastapisAPIRouter-Dekoratoren als No-Ops, ein synchronessqlite3-basiertes Shim mit aiosqlite-kompatiblemasync-Interface) erlauben es, die echten Endpunkt-Funktionen ausapp/admin/routes.pydirekt (ohne HTTP-Layer) gegen eine echte In-Memory-sqlite3-Datenbank mit allen 7 Migrationen auszufuehren — damit wurden ueber 20 Integrations-Assertions (Mandanten-Isolation, Rollen-Mehrfachvergabe, Delete-Semantik) tatsaechlich lauffaehig verifiziert, nicht nur simulierte SQL-Queries. Der neuetests/test_tenants.pyselbst nutzt weiterhinhttpx.AsyncClientgegen 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 mitpytestausgefuehrt. Fuer Phase 9 zusaetzlich: die reparierte Kontrollfluss-Logik vondiscover_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), Migration0008_credentials_roles.sqlwurde 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-Moduleapp/security/log_stream.pyundapp/security/active_sessions.py(kein DB-/HTTP-Bezug) wurden direkt importiert und gegen echtesasyncio/loggingausgefuehrt statt nur gelesen. Alle geaenderten Python-/JS-Dateien wurden mitpy_compile/node --checkauf Syntaxfehler geprueft, und jederdocument.getElementById-Aufruf inadmin.jswurde automatisiert gegen die tatsaechlichen HTML-IDs inadmin.htmlabgeglichen (0 Abweichungen). Bittepytest -qlokal 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.ymlsowiepip-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 inPentest_Report.md. Fuer die in dieser Session neu hinzugekommenen Dateien (Admin-Oberflaeche, Gruppen-RBAC, API-Tokens) steht ein erneuterbandit/pip-audit-Lauf noch aus (keine neuen Fremdabhaengigkeiten hinzugekommen). - DAST:
tests/test_pentest_security.py(17 Tests) sowietests/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.txtexakt auf gegenpip-auditgepruefte Versionen gepinnt (u.a. fastapi 0.141.1, starlette 1.6.0, cryptography 50.0.0) — bewusste Reproduzierbarkeits-/ Haertungsmassnahme, siehePentest_Report.mdAbschnitt 2.
Deployment
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:
- 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.mdbzw. oben). - 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 laufendenguacd+ 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. - Ansible-Rollout gegen ein reales Zielsystem (
--check-Dry-Run und echter Rollout in einer Staging-Umgebung) — bisher nuransible-playbook site.yml --syntax-checksowie YAML-/Jinja2-Parsing verifiziert, sieheansible/roles/os_hardening/CIS_STIG_MAPPING.md. - OpenSCAP-Compliance-Scan (
oscap xccdf eval) gegen das zutreffende CIS/STIG-Profil — dieos_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. - 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. - 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. - 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.
- Alle in Konzept Kap. 12 genannten Erweiterungen (LDAP/AD, WebAuthn, PostgreSQL-Migrationspfad, Just-in-Time-Zugriff, HA) sind noch nicht umgesetzt.
- Social Engineering / physische Sicherheit / Lastest (DoS) wurden nicht getestet — ausserhalb des Scopes eines Code-/Konfigurations-Reviews in dieser Sandbox-Umgebung.
- 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.
- Ein erneuter
bandit/pip-audit-Lauf ueber die in dieser und der Phase-8-Session neu hinzugekommenen Dateien (Admin-Oberflaeche, Gruppen-RBAC, API-Tokens, Mandantenfaehigkeit, Dateitransfer-Panel) steht noch aus (keine neuen Fremdabhaengigkeiten hinzugekommen, siehe oben). - 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 robustereshowHostDetail()-Logik sollten das Problem loesen, sollten aber nach dem Deployment einmal manuell im Browser bestaetigt werden.