77 KiB
Umsetzungsauftrag für Sonnet 5 — Jumphost
Stand: 2026-08-31 · Codestand: Commit afe6719 (Arbeitsbaum == HEAD)
Zielsystem: Debian 12 (bookworm) — geändert gegenüber der ersten Fassung, siehe Teil A.2.
Grundlage: Analyse von app/**, static/js/**, templates/**, app/db/migrations/**,
ansible/**, tests/** sowie des Vorgängerberichts RDP_Diagnose_Phase16.md.
Dieses Dokument beschreibt was zu tun ist und warum, mit genauen Fundstellen. Es enthält bewusst keinen fertigen Code — die Implementierung ist Aufgabe von Sonnet 5.
0. Auftrag in fünf Sätzen
- RDP zu Windows-Zielen funktioniert nicht (Bildschirm bleibt schwarz, Log meldet
[Errno 111] Connection refused). Ursache finden, beheben, und vor allem: die Fehlermeldungen so umbauen, dass die nächste Störung in einer Minute statt einem Tag erklärbar ist. Das Zielsystem ist jetzt Debian 12 — dort muss RDP funktionieren; das ist die Abnahmebedingung, keine Option. - Mehrere Benutzer müssen gleichzeitig Sitzungen offen haben können (Teil E). Es gibt heute keine harte Sperre dagegen, aber mehrere Stellen, die unter Parallelbetrieb die Anwendung ausbremsen oder Daten beschädigen — allen voran eine Race Condition in der Audit-Hash-Kette.
- Ein Benutzer soll mehrere Sitzungen gleichzeitig offen haben und über eine Seitenleiste zwischen ihnen umschalten können (Teil F). Das ist heute nur über mehrere Browser-Tabs möglich; eine Oberfläche dafür gibt es nicht.
- Das Mandanten-Konzept (Tenants) wird vollständig entfernt. Es bringt für diese Anwendung keinen Nutzen und verteuert jede weitere Änderung.
- Berechtigungen auf Server, Hostgruppen und Zugangsdaten laufen künftig ausschließlich über Benutzergruppen. Die Direktvergabe pro Benutzer entfällt; für Zugangsdaten wird erstmals eine Berechtigungsebene eingeführt.
Arbeitsregeln
- Reihenfolge einhalten: Teil A/B (RDP + Fehlermeldungen) → Teil E (Parallelbetrieb) →
Teil C (Mandanten) → Teil D (Berechtigungen) → Teil F (Multi-Session-Oberfläche).
Teil D setzt Teil C voraus (
rdp_credentials.tenant_idist heuteNOT NULLund würde in die neuen Freigabetabellen hineinregieren). Teil F setzt Teil E voraus — eine Oberfläche mit fünf gleichzeitig offenen Sitzungen auf einem Event-Loop, der schon bei zwei RDP-Sitzungen einbricht, wäre eine Verschlechterung. Zwei Punkte aus Teil E sind sofort zu erledigen, unabhängig von allem anderen: die Audit-Race (E1) und der Startup-Reaper (E7) — beide richten bereits heute im Mehrbenutzerbetrieb Schaden an. - Migrationen sind vorwärts-only. Bestehende Dateien in
app/db/migrations/niemals editieren — der Runner (app/db.py:41-56) führt überschema_migrationsBuch. Neue Schritte bekommen neue Nummern ab0013. - Jede Teilstufe muss für sich lauffähig sein: Dienst startet, Login funktioniert, bestehende SSH-Sitzungen brechen nicht.
- Die Testsuite ist laut README nie vollständig gelaufen. Sie soll am Ende von Teil D grün sein; Abweichungen davon explizit dokumentieren.
- Vor jeder Schemaänderung: Backup der SQLite-Datei inkl.
-wal/-shmbei gestopptem Dienst.
TEIL A — Warum RDP nicht funktioniert
A.0 Die Kernaussage zum gemeldeten Log
DEBUG RDP-Sitzung 28 gestartet: user=admin host=DBPortal-12.250 (10.0.0.12:3389)
WARNING RDP-Sessionfehler (session_id=28): [Errno 111] Connection refused
DEBUG RDP-Sitzung 28 beendet: reason=error
Dieser Fehler hat nichts mit 10.0.0.12:3389 zu tun. Die Anwendung öffnet auf dem
gesamten RDP-Pfad genau einen ausgehenden Socket: asyncio.open_connection(guacd_host, guacd_port) in app/rdp_proxy/guacd_client.py:134, aufgerufen aus
app/rdp_proxy/ws_tunnel.py:235-238 mit settings.guacd_host/settings.guacd_port
(Defaults 127.0.0.1:4822, app/config.py:68-69). Zum Windows-Ziel verbindet guacd,
nicht die App — ein Fehler dort käme als Guacamole-error-Instruktion zurück und würde in
guacd_client.py:157-159 zu einem GuacamoleProtocolError, nicht zu einem OSError.
ConnectionRefusedError wird in ws_tunnel.py:256 gefangen und in :257 geloggt.
Schlussfolgerung: Auf 127.0.0.1:4822 lauscht nichts. Die vorangehende DEBUG-Zeile mit
dem Zielhost (ws_tunnel.py:212-215) ist in diesem Kontext irreführendes Rauschen — sie ist
mit ein Grund, warum die Meldung „nicht vielsagend" ist (siehe Teil B).
Die Firewall scheidet als Erklärung aus: ansible/roles/firewall_nftables/templates/jumphost.nft.j2:8
(iif lo accept) und :31 (oif lo accept) lassen Loopback durch, und ein policy drop
liefert EPERM, nicht ECONNREFUSED.
A.1 Sofortdiagnose auf dem Jumphost (vor jeder Codeänderung)
In dieser Reihenfolge, das Ergebnis entscheidet über A.2:
systemctl status guacdundjournalctl -u guacd -n 50ss -ltnp | grep 4822ls /usr/lib/*/libguac-client-rdp.so* /usr/local/lib/libguac-client-rdp.so*nft list ruleset | grep daddr- Im Adminbereich beim betroffenen Host prüfen, ob als Benutzername der Platzhalter
(kein Benutzername)eingetragen ist (siehe A.3, Punkt C2).
A.2 Warum guacd nicht lauscht — auf Debian 12 (bookworm)
Geänderte Rahmenbedingung (2026-08-31): Zielsystem ist jetzt Debian 12 (bookworm), nicht
mehr Debian 13. Damit fällt die schwerwiegendste Ursache der bisherigen Analyse weg: der
Konflikt zwischen guacamole-server und FreeRDP 3.x (GUACAMOLE-2146), an dem der Quellcode-Build
auf Debian 13 scheiterte, existiert unter bookworm nicht — dort liegt FreeRDP 2.x
(freerdp2-dev, in den Point-Releases auf 2.11.x aktualisiert), und guacamole-server 1.5.5 ist
genau dagegen geschrieben.
Wichtig — hier keine falsche Erwartung aufbauen: Debian hat auch in bookworm KEIN
guacd-Paket. Die Quelle guacamole-server wurde im Januar 2022 aus Testing entfernt und
ist zuletzt in bullseye (1.3.0-1) enthalten gewesen; in bookworm und trixie gibt es sie nicht.
Nur Ubuntu paketiert guacd (Komponente „universe"). Der Quellcode-Build bleibt auf Debian 12
also der Weg — und genau das tut die Rolle bereits: ansible/roles/guacd/tasks/main.yml:44-61
wählt für Debian 12 automatisch _guacd_install_mode: source, _guacd_build_mode: release
(Tarball 1.5.5, Checksum-verifiziert) und freerdp2-dev. Der Umstieg auf Debian 12
erfordert an dieser Weiche keine Änderung; er macht den vorhandenen Pfad zum ersten Mal
lauffähig. Der git-main-Pfad (:138-157) wird auf Debian 12 nie betreten und kann
unangetastet bleiben.
Vor dem ersten Ansible-Lauf trotzdem einmal auf dem Zielsystem gegenprüfen (Versionen
nicht raten): apt policy freerdp2-dev libfreerdp2-2 und, falls doch ein Paketweg gewünscht
ist, apt-cache policy guacd. Die Rolle soll gegen das prüfen, was das System tatsächlich
anbietet, und mit einer klaren Meldung abbrechen statt in einen halben Build zu laufen.
Die verbleibenden Ursachen — alle unverändert gültig
| # | Ursache | Fundstelle | Warum das gilt |
|---|---|---|---|
| 1 | Kein service: state=started enabled=true für guacd |
ganze Rolle ansible/roles/guacd/tasks/main.yml (kein service:-Task; nur der Handler handlers/main.yml:6-9) |
guacd wird nur bei einer Änderung neu gestartet. Ein zweiter Ansible-Lauf ohne Änderung meldet „ok", während der Dienst tot ist. Nach dem Wegfall des Build-Problems ist das die wahrscheinlichste verbleibende Ursache für Errno 111. Vergleiche jumphost_app/tasks/main.yml:71-76, wo es richtig gemacht ist. |
| 2 | Keine Startprüfung | dieselbe Rolle | Kein wait_for auf {{ guacd_port }}, keine Existenzprüfung auf das RDP-Plugin. Ein fehlgeschlagener Start bleibt bis zum ersten Benutzerversuch unbemerkt. |
| 3 | --sysconfdir fehlt beim configure |
tasks/main.yml:159-165 (./configure --with-systemd-dir=/etc/systemd/system --disable-guacenc --disable-guaclog) gegen tasks/main.yml:197-211 |
Ohne --sysconfdir=/etc ist das Konfigurationsverzeichnis des Builds /usr/local/etc. guacd liest dann /usr/local/etc/guacamole/guacd.conf — die Rolle schreibt aber nach /etc/guacamole/guacd.conf. Heute unauffällig, weil guacd ohne Konfiguration ohnehin auf 127.0.0.1:4822 lauscht; sobald guacd_port oder bind_host geändert wird, ist Errno 111 die exakte Folge. Entweder --sysconfdir=/etc ergänzen oder die Konfiguration dorthin schreiben, wo der Build sie sucht — und danach verifizieren, dass der tatsächlich lauschende Port dem entspricht, was die App konfiguriert hat. |
| 4 | /etc/guacamole wird nicht angelegt |
tasks/main.yml:197-211 (lineinfile ... create: true) |
lineinfile legt die Datei an, nicht das Verzeichnis. Existiert /etc/guacamole nicht, bricht die Rolle hier ab — vor dem Start-Handler. Gehört zusammen mit Punkt 3 gelöst. |
| 5 | Das Hardening-Drop-in kann den Dienst am Start hindern | templates/guacd.service.j2:11 (ProtectSystem=strict), :24 (ReadWritePaths= nur rdp-drives), :8 (User=guacd), ausgerollt in tasks/main.yml:229-237 |
Das Drop-in überschreibt die vom Build installierte Unit. ProtectSystem=strict macht das gesamte Dateisystem außer den ReadWritePaths schreibgeschützt — inklusive eines etwaigen PID-/Laufzeitverzeichnisses. Vorgehen: systemctl cat guacd lesen, das Drop-in gezielt darauf abstimmen (Benutzer, Type=/PIDFile=, Laufzeit- und Schreibpfade), nicht blind übernehmen. systemd-analyze security guacd zeigt anschließend, ob die Härtung noch greift. |
| 6 | Port-Divergenz App ↔ guacd | ansible/inventory/group_vars/all.yml:16 (guacd_port: 4822), jumphost-app.service.j2:14-15, app/config.py:68-69 |
Solange beide Seiten auf 4822 stehen, unauffällig — aber nur zufällig, siehe Punkt 3. Ein Test „App-Port == tatsächlich lauschender Port" gehört in die Rolle. |
| 7 | Reihenfolge | ansible/site.yml:20-21, jumphost-app.service.j2:3-4 (After=/Wants=guacd.service) |
Der Handler restart guacd wird erst am Play-Ende geflusht, also nach dem Start der App. Wants= mildert das, hilft aber nicht bei einer failed Unit. Besser: expliziter Start plus Portprüfung vor der Rolle jumphost_app. |
Zu tun (Ansible-Rolle guacd, Debian-12-Fassung)
- Weiche und Build-Pfad bleiben wie sie sind (Release-Tarball 1.5.5 +
freerdp2-dev); den Kommentarblocktasks/main.yml:1-42unddefaults/main.yml:1-26auf den neuen Zielstand eindampfen, damit niemand später wieder nach einem Debian-Paket sucht. - Expliziter Start-/Enable-Task plus
wait_forauf den konfigurierten Port mit sprechender Fehlermeldung. - Prüfung, dass das RDP-Plugin gebaut und installiert wurde (
libguac-client-rdp.soim Bibliothekspfad), sonst harter Abbruch mit Klartext — sonst meldet guacd erst zur LaufzeitSupport for protocol "rdp" is not installed. Der Build überspringt das RDP-Plugin stillschweigend, wenn die FreeRDP-Header fehlen; genau dieser Fall muss auffallen. --sysconfdirund das Konfigurationsverzeichnis geradeziehen (Punkte 3 und 4).- Das systemd-Drop-in gegen die tatsächlich installierte Unit prüfen (Punkt 5).
- Sicherstellen, dass der Benutzer, unter dem guacd läuft, den Laufwerkspfad erreichen kann (siehe A.3, Befund B6, und Befund E6 in Teil E zur sitzungsbezogenen Trennung dieses Pfads).
- Nach dem Ansible-Lauf ein Smoke-Test-Task: TCP-Verbindung auf den guacd-Port und Abgleich mit dem in der App konfigurierten Port.
A.3 Weitere RDP-Blocker
Alle 18 Befunde aus RDP_Diagnose_Phase16.md sind unverändert offen. Der einzige Commit
nach dem Bericht (8ddb3ae) ändert zwei kosmetische Zeilen am Exit-Button in
static/js/rdp.js. Die folgende Tabelle ist die verbindliche Arbeitsliste.
| ID | Datei:Zeile | Problem | Auswirkung |
|---|---|---|---|
| A1 | app/rdp_proxy/guacd_client.py:44-66 |
_read_until()/_read_chars() lesen mit readexactly(1) byteweise |
~1 MB/s Obergrenze bei voller Event-Loop-Blockade; ein RDP-Bildstrom besteht fast nur aus base64-blob-Instruktionen. Phase 16 misst Faktor 644x gegenüber gepuffertem Lesen. Bild friert ein, Maus verzögert, alle anderen SSH-/RDP-Sitzungen und die Admin-UI stehen mit. Umbau auf readuntil(b".") + readexactly(len), dazu limit am open_connection erhöhen, damit große Elemente kein LimitOverrunError auslösen. Die Zeichen- statt Byte-Semantik aus Phase 10 muss erhalten bleiben. |
| A2 | app/rdp_proxy/ws_tunnel.py:235-246 |
await open_tunnel(...) läuft vor dem Start der Pumpen-Tasks |
Während des gesamten guacd-/RDP-Handshakes liest niemand vom WebSocket. guacamole-common-js bricht nach receiveTimeout = 15000 (static/js/vendor/guacamole-common.js:14605) ohne empfangenen Frame mit „Server timeout." ab. Effektiv 15 Sekunden für den kompletten Windows-Anmeldevorgang inkl. NLA/CredSSP. Leseschleife vor dem Tunnelaufbau starten bzw. den Client während des Aufbaus bedienen. |
| A3 | app/rdp_proxy/guacd_client.py:134, :139, :157 |
Weder Verbindungsaufbau noch die beiden read_instruction-Aufrufe haben ein Timeout; app/config.py kennt keine guacd-Timeout-Einstellung |
Antwortet guacd nach dem TCP-Accept nicht mehr, hängt die Coroutine unbegrenzt: sessions-Zeile ohne ended_at, offene Recording-Datei, Eintrag in active_sessions, belegtes WebSocket. Konfigurierbares Timeout je Phase einführen. |
| C1 | app/rdp_proxy/guacd_client.py:142 + :153 |
arg_names = args_instr[1:] — das erste Element ist bei guacd ≥ 1.5 das Versions-Token VERSION_1_5_0, dafür wird ein Leerstring gesendet |
guacd stuft auf Protokoll 1.0.0 zurück. Verloren gehen timezone, required (Nachfordern von Zugangsdaten) und msg. Versions-Token erkennen und korrekt beantworten. |
| C2 | app/db/migrations/0012_rdp_credential_sets.sql:51 |
Migration schreibt '(kein Benutzername)' als echten Benutzernamen |
Die Leerprüfung in guacd_client.py:204-209 greift nicht, der Platzhalter geht als Anmeldename an Windows → „Anmeldung fehlgeschlagen" statt klarer Meldung. Datenkorrektur per neuer Migration plus Prüfung im Anmeldepfad. |
| C3 | app/rdp_proxy/guacd_client.py:213-228 |
In der connect-Instruktion fehlen timezone, client-name, width/height/dpi |
Windows übernimmt die Serverzeitzone; die Auflösung funktioniert nur, weil guacd bei leeren Werten auf eine Default-Größe zurückfällt. |
| B5 | ansible/.../jumphost.nft.j2:28-40, ansible/inventory/group_vars/all.yml:30-31 |
Output-Chain policy drop, ausgehend nur target_networks (Default 10.20.0.0/16) erlaubt |
Das gemeldete Ziel 10.0.0.12 liegt nicht in diesem Netz. Sobald guacd wieder läuft, werden dessen RDP-Pakete still verworfen (kein Reject, kein Log) → TCP-Timeout → in Kombination mit A2 sieht der Benutzer „Server timeout" statt „Ziel nicht erreichbar". Zweitwichtigster Blocker nach guacd selbst. target_networks an die tatsächlichen Zielnetze anpassen und im Fehlerfall ein Logging-/Reject-Verhalten vorsehen, das diagnostizierbar ist. |
| B6 | app/rdp_proxy/guacd_client.py:222-224, ansible/roles/sqlite_init/tasks/main.yml:6-12, app/db.py:25, guacd.service.j2:8 |
drive-path liegt unter /var/lib/jumphost, das 0700 jumphost:jumphost ist; guacd läuft als guacd |
Laufwerksumleitung schlägt bei jedem Host mit file_transfer_enabled mit EACCES fehl. Verzeichnisrechte bzw. Gruppenzugehörigkeit klären. |
| D1 | app/catalog/routes.py:48, :56 |
Filter auf r.name IN ('ssh_connect','rdp_connect') ohne Abgleich mit h.protocol |
Ein Benutzer mit nur ssh_connect sieht Windows-Hosts inkl. „Verbinden"-Knopf und landet in der Ablehnung 4403 (ws_tunnel.py:145-149). Siehe auch Teil D, S6. |
| D2 | app/recordings/recorder.py:40-41 |
write() + flush() bei jeder Instruktion, synchron im Event-Loop |
Der RDP-Bildstrom wird 1:1 als JSONL gespiegelt. Wenige Stunden füllen /var/lib/jumphost/recordings mit GB; läuft die Partition voll, sterben SSH-Sitzungen und DB mit. Verstärkt zusätzlich A1. Puffern, Rotation und Größenbegrenzung vorsehen. |
| D3 | app/main.py:91 |
CSP img-src 'self' data: — kein blob: |
guacamole-common-js bevorzugt createImageBitmap() (vendor/guacamole-common.js:4854), fällt aber auf URL.createObjectURL() zurück (:4876). Auf einem Browser ohne createImageBitmap bleibt das Bild wirklich schwarz, weil die CSP die Blob-URL blockt. |
| D4 | static/js/rdp.js (gesamt) |
client.sendSize() wird nie aufgerufen, kein resize-Listener — obwohl resize-method: display-update gesetzt ist (guacd_client.py:227) |
Auflösung bleibt auf dem Fenstermaß beim Verbindungsaufbau eingefroren. |
| D5 | app/rdp_proxy/ws_tunnel.py:96-118 |
parse_instruction_text() parst nur die erste Instruktion einer WS-Nachricht (guacd_client.py:99-100 bricht bei ; ab), weitergeleitet wird die komplette Nachricht (:117) |
Der Clipboard-Filter (:113-114) greift nur auf die erste Instruktion — eine clipboard-Instruktion an zweiter Stelle passiert ungefiltert. Policy-Umgehung, sicherheitsrelevant. |
Explizit korrekt — hier nicht suchen
Handshake-Reihenfolge select → args → size/audio/video/image → connect → ready
(guacd_client.py:136-159); Subprotokoll-Bestätigung (ws_tunnel.py:52-59); Tunnel-UUID als
erste Instruktion (:160-161); Ping-Spiegelung (:109-110); Zeichen- statt Byte-Längen
(guacd_client.py:41, :53-65); security=nla / ignore-cert=true als Defaults
(guacd_client.py:219-220); Passwortentschlüsselung in try/finally (ws_tunnel.py:231);
leeres RDP-Passwort ist per Schema ausgeschlossen (app/models/schemas.py); nginx reicht
/ws/ mit Upgrade und 3600 s durch.
TEIL B — Fehlermeldungen und Diagnostizierbarkeit
Der Nutzer nennt das ausdrücklich als eigenes Problem: „auch sind die Fehlermeldungen nicht vielsagend". Das ist kein Komfortthema — die aktuelle Störung wäre mit brauchbaren Meldungen in einer Minute erklärt gewesen.
B.1 Warum der Bildschirm „schwarz bleibt"
static/js/rdp.js:43-53 schreibt Fehler in ein 0,85 rem großes rotes <span> in der
Toolbar, während #rdp-display { flex: 1; background: black; } (static/css/app.css:198)
die restliche Seite ganzflächig schwarz färbt. Zusätzlich liefert
parseInt(event.reason) in vendor/guacamole-common.js:15556 für den deutschen Text NaN
— der Statuscode geht verloren.
Es gibt also eine Meldung, sie ist nur eine Randnotiz auf schwarzem Grund. Nötig ist ein
Overlay-Panel über #rdp-display mit Titel, Klartext, Angabe von guacd- und Zieladresse,
Zeitpunkt, Sitzungs-ID und einem Wiederholen-Knopf.
B.2 Mängelliste Server-Seite
| Datei:Zeile | Problem | Was stattdessen gemeldet werden muss |
|---|---|---|
app/rdp_proxy/ws_tunnel.py:256-257 |
except (GuacamoleProtocolError, ConnectionError, OSError) → eine Warnung mit Sitzungs-ID und blankem Exception-Text. Kein Schritt, kein Endpunkt, kein Stacktrace. Die Zeile darüber nennt den Zielhost und lenkt damit aktiv in die falsche Richtung. |
Zwei Fehlerklassen sauber trennen: (a) „guacd unter <host:port> nicht erreichbar — Dienst prüfen", (b) „guacd meldet <Klartext> (Code <n>) für Ziel <host:port>". Schritt (connect-guacd / handshake / ready / stream) und die guacd-Adresse gehören in jede Meldung. |
app/rdp_proxy/guacd_client.py:134 |
open_connection ohne try/except, der OSError propagiert nackt |
In einen eigenen Fehlertyp mit guacd_host:guacd_port im Text umschließen; ConnectionRefusedError und TimeoutError unterscheiden. |
app/rdp_proxy/guacd_client.py:159 |
raise GuacamoleProtocolError(f"Verbindungsaufbau fehlgeschlagen: {ready_instr}") → im Browser landet ein Python-Listen-repr |
Guacamole-Statuscodes auf deutsche Klartexte abbilden (769 Anmeldung fehlgeschlagen, 519 Ziel nicht erreichbar, 512 Serverfehler, 776/777 Zertifikat/TLS) und die Meldung von guacd verwenden. |
app/rdp_proxy/ws_tunnel.py:269-277 |
except Exception → der Browser bekommt nur den Klassennamen. Hier landet u. a. IncompleteReadError (guacd hat abgebrochen — die häufigste Folge eines FreeRDP-Absturzes), weil EOFError kein OSError ist |
IncompleteReadError/EOFError explizit fangen: „guacd hat die Verbindung unerwartet beendet". |
app/rdp_proxy/ws_tunnel.py:296-308 |
Fehlertext wird auf 123 Byte gekürzt (:300) und nur als WS-Close-Reason gesendet — nach drei await conn.execute(...). Schlägt eine davon fehl, erfährt der Browser gar nichts. Das abschließende except Exception (:304) loggt nur auf DEBUG |
Vor dem Schließen eine vollständige Guacamole-error-Instruktion über den offenen Tunnel senden — dann greift der Handler in vendor/guacamole-common.js:3101-3110 und die Länge ist nicht begrenzt. Close-Reason nur als Fallback. |
app/rdp_proxy/ws_tunnel.py:96-98 |
except (…): continue — ungültige Clientnachrichten werden völlig lautlos verworfen |
Mindestens ein DEBUG-Log mit gekürztem Nachrichtenpräfix. |
app/rdp_proxy/ws_tunnel.py:212-215, :239 |
Die einzigen aussagekräftigen Kontextzeilen sind DEBUG. Im Journal (basicConfig(level=INFO), app/main.py:28) sieht man nur die Warnung ohne jeden Kontext; sichtbar sind sie nur, weil app/security/log_stream.py:56 den jumphost-Namespace global auf DEBUG hebt |
Sitzungsstart/-ende und Tunnelaufbau auf INFO. |
| gesamter RDP-Pfad | Keine Correlation-ID. session_id existiert erst ab ws_tunnel.py:202; alle Ablehnungen davor (:135-136, :145-149, :166, :170-174, :190-196) haben kein Korrelationsmerkmal, _reject (:76) loggt nicht einmal host_id oder Benutzer |
Eine Verbindungs-UUID beim WS-Accept erzeugen (die tunnel_uuid aus :160 bietet sich an) und in jede Logzeile sowie in die Meldung an den Browser aufnehmen. |
app/main.py:136-138 |
/healthz liefert statisch „ok" — prüft weder DB noch guacd |
Tiefe Variante mit TCP-Probe auf guacd_host:guacd_port, damit „guacd tot" ohne Sitzungsversuch sichtbar wird. |
ansible/roles/guacd/tasks/main.yml |
keinerlei Verifikation nach der Installation | siehe A.2. |
B.3 Abnahmekriterium für Teil B
Drei Störfälle künstlich herbeiführen und prüfen, dass Log und Browser jeweils unterscheidbar und in Klartext berichten:
- guacd gestoppt → „guacd nicht erreichbar", mit Adresse und Hinweis auf den Dienst.
- Zielhost nicht erreichbar/geblockt → „Ziel
<host:port>nicht erreichbar", nicht „Server timeout". - Falsches Passwort am Windows-Ziel → „Anmeldung fehlgeschlagen (769)".
TEIL C — Mandanten (Tenants) vollständig entfernen
C.1 Was Mandanten heute tatsächlich tun
Mandanten sind kein Routing-Konzept: es gibt weder Header-, Subdomain- noch
Cookie-Auflösung und keinen Default-Tenant-Fallback. Der Mandant wird ausschließlich aus dem
authentifizierten Principal abgeleitet (app/auth/deps.py:276-290, effective_tenant_ids()):
Super-Admin → „alle", Benutzer mit Zeilen in tenant_admins → genau diese IDs, API-Token →
exakt api_tokens.tenant_id. Daraus baut app/admin/routes.py:94 einen TenantScope
(app/tenancy.py:22-56), der zwei Dinge tut: ein SQL-Fragment für Listen-Endpunkte liefern
und bei fremden IDs bewusst 404 statt 403 werfen.
Fachlich hängt der Mandant an fünf Objekttypen: Hostgruppe, Benutzergruppe, SSH-Key,
RDP-Zugangsdatensatz, API-Token. Hosts haben keine eigene tenant_id — sie erben sie über
die Hostgruppe (app/tenancy.py:65-71). users.home_tenant_id ist rein informativ; die
Sichtbarkeit eines Benutzers wird dynamisch über tenant_user_ids() (app/tenancy.py:74-97)
berechnet.
Das Rollenmodell (app/rbac.py) ist vollständig mandantenfrei. Auch Dashboard/Katalog,
SSH-Proxy, RDP-Proxy, SFTP, Recordings und alle app/security/*-Module enthalten null
Tenant-Bezüge. Mandantenfähigkeit ist ein reiner Aufsatz auf der Admin-API plus deren UI —
der Rückbau ist deshalb gut beherrschbar.
C.2 Inventar Schema
Eigene Tabellen (komplett entfernbar): tenants (0006_tenants.sql:17-23, Seed
(1,'Standard') in :24-25), tenant_admins (:32-38) samt Index :39.
Spalten in Fremdtabellen:
| Tabelle | Spalte | NOT NULL | FK | Index | Quelle |
|---|---|---|---|---|---|
host_groups |
tenant_id |
nein (Backfill 1) | tenants(id) |
idx_host_groups_tenant |
0006:57-58, 87 |
user_groups |
tenant_id |
nein (Backfill 1) | tenants(id) |
idx_user_groups_tenant |
0006:60-61, 88 |
ssh_keys |
tenant_id |
nein (Backfill 1) | tenants(id) |
idx_ssh_keys_tenant |
0006:63-64, 89 |
api_tokens |
tenant_id |
nein (nur anwendungsseitig erzwungen) | tenants(id) |
idx_api_tokens_tenant |
0006:66-76, 90 |
users |
home_tenant_id |
nein, kein Backfill | tenants(id) |
idx_users_home_tenant |
0006:78-85, 91 |
rdp_credentials |
tenant_id |
ja | tenants(id) |
idx_rdp_credentials_tenant |
0012:19-29 |
Wichtigstes Einzelergebnis: es gibt nirgends ein UNIQUE(tenant_id, name). Alle
Namensspalten sind seit jeher global eindeutig (host_groups.name, user_groups.name,
users.username, roles.name, api_tokens.token_hash); ssh_keys.label und
rdp_credentials.label sind gar nicht eindeutig. Der Wegfall von tenant_id verschärft
also keine einzige Eindeutigkeitsbedingung — Duplikat-Kollisionen sind ausgeschlossen.
(Umgekehrt gelesen war das ein Konstruktionsfehler: zwei Mandanten konnten nie eine
gleichnamige Hostgruppe haben, ein Mandanten-Admin konnte über eine 409-Antwort fremde
Namen erraten — genau das, was TenantScope mit 404 verhindern wollte.)
Views gibt es keine. Trigger nur zwei, beide auf audit_log (0001:136-139, append-only,
ohne Tenant-Bezug) — relevant für C.5.
C.3 Inventar Code und UI
Komplett löschbar: app/tenancy.py (97 Zeilen, einziger Importeur app/admin/routes.py:83).
app/auth/deps.py: CurrentUser.tenant_admin_ids/.token_tenant_id (:25-32),
is_any_admin (:34-37), _load_tenant_admin_ids() (:40-43) und der Aufruf :81-82,
Kommentarblock :160-171, at.tenant_id im Token-SELECT (:180-189, :204),
Autorisierung über is_any_admin (:233, :269), effective_tenant_ids() (:276-290).
app/auth/routes.py:329-344: tenant_admin_of[] und is_any_admin aus GET /auth/me.
app/admin/routes.py (179 Treffer): Helfer _scope() :94-95, _tenant_exists()
:98-100, _resolve_write_tenant() :103-121; kompletter Tenant-CRUD-Block :124-261
(POST/GET/PUT/DELETE /admin/tenants, GET/POST/DELETE /admin/tenants/{id}/admins);
Scoping in den Fachendpunkten: Users :274-279, :290-292, :307-329, :335-343,
Benutzergruppen :482-493, :503-515, :521-527, Hostgruppen :637-645, :655-663, :668-673,
Hosts :765-783, :789-793, :810-841, RDP-Zugangsdaten :1173-1179, :1197-1216, :1224-1231,
SSH-Keys :1501-1518, :1556-1569, :1581-1586, Rollen :1360-1368, :1444-1452,
API-Tokens :1745-1749, :1767-1789, :1802-1815, :1826-1831, Audit-Log :1846-1875,
Kommentare :1882-1883, :1891-1893. app/admin/log_ws.py:20 nur Kommentar.
app/models/schemas.py: Klassen TenantCreateRequest (:81-84), TenantUpdateRequest
(:86-90), TenantAdminAssignRequest (:92-93); Felder UserCreateRequest.home_tenant_id
(:61-65), HostGroupCreateRequest.tenant_id (:99-101), UserGroupCreateRequest.tenant_id
(:182), ApiTokenCreateRequest.tenant_id (:212-214), SshKeyCreateRequest.tenant_id
(:222), RdpCredentialCreateRequest.tenant_id (:267).
templates/admin.html: Tab-Button :32; Panel #tab-tenants :622-679; fünf
Mandanten-Auswahlblöcke :107-109, :173-175, :364-366, :466-468, :592-594; Spalte
„Mandant" in sieben Tabellenköpfen :66, :120, :182, :254, :392, :480, :614; Hinweistext :582.
static/js/admin.js: meInfo :10; Tab-Loader :114; populateTenantSelect/
cachedTenants :143-172; tenantName() :176-180; Vorladen :183-185;
Select-Befüllung :280-281, :407-408, :763-767, :1138-1139; Tabellenzellen
:220, :295, :423, :475, :543, :783, :935, :1169; tenant_id in fünf Request-Bodies
:370-372, :643-645, :902/911, :1001/1007, :1186/1192; kompletter Mandanten-Tab :1208-1325;
Rollen-Label und Tab-Sichtbarkeit :1905-1909.
Nicht betroffen (verifiziert): app/rbac.py, app/catalog/routes.py, app/ssh_proxy/*,
app/rdp_proxy/*, app/recordings/*, app/security/*, app/main.py, app/config.py,
app/db.py, scripts/, ansible/** (0 Treffer), templates/dashboard.html, alle
Dashboard-/Terminal-/RDP-JS-Dateien. Jumphost_Konzept.md und Pentest_Report.md haben
0 Treffer und brauchen keine Änderung.
C.4 Das zentrale Risiko: stille Rechteausweitung
is_any_admin (app/auth/deps.py:34-37) ist heute is_admin ODER tenant_admin_ids ≠ ∅.
Alle Admin-Endpunkte prüfen nur is_any_admin; die eigentliche Beschränkung eines
Mandanten-Admins entsteht ausschließlich durch TenantScope im Endpunkt selbst.
Wird TenantScope ersatzlos entfernt, ohne vorher das Rollenmodell zu bereinigen, wird
jeder Benutzer mit einer Zeile in tenant_admins schlagartig zum Voll-Admin — Zugriff auf
alle Hosts, alle Zugangsdaten-Metadaten, alle Benutzer, das komplette Audit-Log. Das ist die
gefährlichste Einzelstelle des gesamten Rückbaus.
Ebenso: jedes API-Token ist heute hart auf einen Mandanten begrenzt (deps.py:180-204). Nach
dem Rückbau sieht ein bestehendes Token alles, was sein Scope erlaubt.
Entfallende Filter (was sichtbar würde): GET /admin/users (:307-329), /admin/user-groups
(:503), /admin/host-groups (:655), /admin/hosts (:765), /admin/hosts/{id} (:790),
/admin/ssh-keys (:1556), /admin/rdp-credentials (:1197), /admin/roles (:1360),
/admin/group-roles (:1444), /admin/tokens (:1802), /admin/audit-log (:1853-1871).
C.5 SQLite-Mechanik und Audit-Kette
ALTER TABLE … DROP COLUMN ist für keine der sechs Spalten nutzbar — SQLite verweigert
es, wenn die Spalte indiziert ist oder in einem Fremdschlüssel steht; beides trifft überall
zu, und selbst nach DROP INDEX bleibt die FK-Klausel im DDL. Voller Tabellen-Rebuild
(12-Schritte-Verfahren) für host_groups, user_groups, ssh_keys, api_tokens,
rdp_credentials — und je nach Entscheidung auch users.
Sonderfall users: Ziel von FKs aus mindestens recovery_codes, user_hostgroup_roles (2×),
group_hostgroup_roles, user_group_members (2×), sessions, audit_log, api_tokens (2×),
ssh_keys.owner_user_id, tenant_admins. Ein Rebuild ist hier das riskanteste Einzelstück.
Alternative: home_tenant_id als tote Spalte stehen lassen (Projektkonvention, vgl.
0010:9-14, 0012:12-17) — dann verhindert die verwaiste FK aber DROP TABLE tenants.
Diese Entscheidung dem Betreiber vorlegen; sauberer ist der einmalige Rebuild.
Weitere Fallstricke:
PRAGMA foreign_keysmuss für den Rebuild aus sein.app/db.py:31setzt sie beim Verbindungsaufbau auf ON, und_apply_migrations()(:41-56) fährt jede Migration perexecutescript()auf derselben Verbindung. Die Migration muss die PRAGMA selbst setzen und am Ende zurücksetzen, ohne sich auf ein umschließendes BEGIN zu verlassen. DanachPRAGMA foreign_key_check.DROP TABLE tenantserst ganz zuletzt, nachdem alle FK-Klauseln verschwunden sind.- Beim Rename-Schritt
PRAGMA legacy_alter_tablebeachten — seit SQLite 3.25 schreibtALTER TABLE … RENAME TOReferenzen in anderen Tabellen mit um, was beim 12-Schritte-Verfahren unerwünscht ist (betrifft besondersssh_keys,rdp_credentials,host_groups,user_groups). rdp_credentials.tenant_idistNOT NULL— der Code darf dort nicht vor dem Schema aufhören zu schreiben.- Audit-Kette:
audit_loghat keine Tenant-Spalte, enthält aber historische Eventstenant_created/tenant_updated/tenant_deleted/tenant_admin_granted/…_revoked. Diese Zeilen dürfen nicht angefasst werden — die Triggerno_audit_update/no_audit_delete(0001:136-139) verweigern jedes UPDATE/DELETE, und jede Änderung bräche die Hash-Kette. Historische Tenant-Events bleiben dauerhaft im Log; das gehört ins README.
C.6 Rückbauplan
Phase 0 — entscheiden und sichern
- Ist-Erhebung auf der Produktiv-DB: Anzahl
tenants, Anzahltenant_admins,DISTINCT tenant_idje betroffener Tabelle, Benutzer mithome_tenant_id≠ 1. Steht überall nur die 1, ist der Rückbau eine reine Aufräumaktion. Steht dort mehr, ist er eine bewusste Zusammenlegung getrennter Datenbestände und braucht eine dokumentierte Freigabe. - Entscheidung zu
tenant_admins: Beförderung zuis_admin = 1oder Degradierung zum normalen Benutzer. Ohne diese Entscheidung darf Phase 1 nicht starten (C.4). - Entscheidung zu
users.home_tenant_id: echter Rebuild oder tote Spalte. - Entscheidung zu bestehenden API-Tokens: pauschal widerrufen (empfohlen) oder bewusst weiterlaufen lassen.
- Vollbackup bei gestopptem Dienst. Der Rückbau ist nicht rückwärtskompatibel.
Phase 1 — Rollenmodell bereinigen (eigener kleiner Schritt). Beschluss aus 0.2 auf der
DB umsetzen, mit Audit-Event. Danach verhält sich die Anwendung noch exakt wie vorher, aber
niemand hängt mehr rechtlich an tenant_admins; ab hier ist Phase 2 sicherheitsneutral.
Phase 2 — Code zuerst, Schema danach, in einem Wartungsfenster. Schema-zuerst würde
jeden Listen-Endpunkt sofort mit „no such column" auf 500 werfen. Einzige Ausnahme:
rdp_credentials.tenant_id ist NOT NULL. Bei einem Single-Node-Jumphost mit einer
SQLite-Datei ist ein kurzes Wartungsfenster (Dienst stoppen → Code deployen → Migration →
starten) die sichere Variante.
app/tenancy.pylöschen.app/auth/deps.pybereinigen (Liste in C.3).is_any_admindurchis_adminersetzen. Bewusst entscheiden:require_admin_sessionals eigenständige „nur Session, kein Token"-Schranke erhalten — das ist der Privilege-Escalation-Schutz der Token-Verwaltung und hat mit Mandanten nichts zu tun.require_global_adminbleibt für/admin/sessions*und/docs, nur die Docstrings werden entkoppelt.app/admin/routes.py: Block:124-261ersatzlos löschen, die drei Helfer entfernen, in den gelisteten Fachendpunkten SQL-Fragment,JOIN tenants, Ergebnisfelder, INSERT-Spalte und Scope-Aufrufe entfernen. Die_assert_*_in_scope-Helfer nicht komplett löschen, sondern auf die reine Existenzprüfung reduzieren — sie liefern auch das 404 für „gibt es nicht", und dieses Verhalten muss erhalten bleiben.app/models/schemas.py: drei Klassen und sechs Felder entfernen. Hinweis zur API-Kompatibilität: ohneextra="forbid"ignorieren die Modelle unbekannte Felder — ein Altclient, der weiterhintenant_idsendet, bekommt kein 422. Das ist als weiche Migration gewollt; harte Ablehnung müsste explizit konfiguriert werden.app/auth/routes.py:329-344anpassen — Breaking Change fürstatic/js/admin.js, deshalb zwingend gemeinsam deployen.templates/admin.htmlundstatic/js/admin.jsbereinigen (Listen in C.3). Spaltenzahl in<thead>und Tabellenzeilen müssen paarweise passen — jede vergessene Hälfte verschiebt die ganze Tabelle.tests/test_csp_compliance.pyprüft, dass kein Inline-JS entsteht.- Neue Migration
0013_drop_tenants.sql: Fremdschlüsselprüfung aus → fünfidx_*_tenantdroppen → Rebuild der fünf (bzw. sechs) Tabellen →idx_tenant_admins_userdroppen →tenant_adminsundtenantsdroppen →foreign_key_check→ Fremdschlüsselprüfung wieder an. Kein UNIQUE-Konflikt zu erwarten (C.2), derforeign_key_checkam Ende ist trotzdem Pflicht. - Nach der Migration
GET /admin/audit-log/verifyaufrufen: die Hash-Kette muss unverändert intakt sein.
Phase 3 — Tests und Doku
tests/test_tenants.py(634 Zeilen): die sechs reinen Mandanten-Tests:93, :123, :150, :179, :246, :304löschen; die neun allgemeinen CRUD-/Rollen-Tests:334-634behalten und ent-mandantisieren (Helfer:48,:74-76, Payloads:160, :190, :192, :613); Datei sinnvoll umbenennen, z. B. zutest_admin_crud.py.tests/test_phase9.py:127-138löschen (baut Mandanten-Admin per Direkt-SQL),:22Doc-Zeile,:218-219Payload;tests/test_phase12.py:104, 113, 365, 373, 389, 397, 410(Spalte streichen);tests/test_phase13.py:114, :245, :317-343.- Erwarteter Nebeneffekt:
tests/test_admin_groups_tokens.py:78, 112, 139undtests/test_pentest_security.py:238senden heute Gruppen-Anlagen ohnetenant_idund laufen deshalb gegen den 400 aus_resolve_write_tenant()— das sind bereits jetzt latent rote Tests, die der Rückbau repariert. README.md::52, Abschnitt:108-129(„Phase 8: Mandantenfaehigkeit"),:228,:250,:462-475,:490,:502-504,:629nachziehen; den Verweis in:428auf die SQLite-Rebuild-Begründung in0006_tenants.sqlauf die neue Migration umhängen; ergänzen, dass historischetenant_*-Audit-Events bewusst im Log verbleiben.
TEIL D — Berechtigungen ausschließlich über Benutzergruppen
D.1 Ist-Modell
Rollen (0001_initial.sql:29-39 und 0008_credentials_roles.sql:17-19; Whitelist
app/models/schemas.py:18-28, UI-Liste static/js/admin.js:4-7):
| ID | Name | Gedachte Bedeutung | Tatsächlich durchgesetzt? |
|---|---|---|---|
| 1 | ssh_connect |
SSH-Sitzung auf Hosts der Hostgruppe | ja — app/ssh_proxy/terminal_ws.py:92-97 |
| 2 | rdp_connect |
RDP-Sitzung | ja — app/rdp_proxy/ws_tunnel.py:142-150 |
| 3 | file_transfer |
SFTP Up-/Download | ja — app/ssh_proxy/sftp.py:39-46 |
| 4 | clipboard |
Copy & Paste in der Sitzung | nein — tote Rolle; Clipboard hängt nur an hosts.clipboard_enabled (ws_tunnel.py:113, 240), SSH gar nicht |
| 5 | session_recording_view |
Playback fremder Sitzungen | nein — tote Rolle; Playback ist require_global_admin (app/admin/routes.py:1971, 1996) |
| 6 | admin_hostgroup |
delegierte Verwaltung einer Hostgruppe | nein — tote Rolle, kein einziger Codepfad prüft sie |
| 7 | credentials_view |
sehen, welche Keys/Sets an einem Host hängen | ja — app/admin/routes.py:1114, app/catalog/routes.py:91 |
| 8 | credentials_manage |
zusätzlich zuordnen/entfernen | ja — app/admin/routes.py:1000, 1038, 1061, 1095, 1697, 1725 |
Nur 5 von 8 Rollen wirken. Die drei toten Rollen sind vergebbar und erzeugen eine falsche
Sicherheitserwartung — clipboard besonders, weil Jumphost_Konzept.md:134-135 damit wirbt.
Prüf-Logik (app/rbac.py, 47 Zeilen): user_has_role() (:13-37) ist ein einziges
Statement mit zwei per UNION verodere Zweigen — Zweig A über user_hostgroup_roles
(:18-23), Zweig B über group_hostgroup_roles JOIN user_group_members (:25-31), beide
mit Ablaufprüfung auf expires_at. Kombination ist eine reine Vereinigung: keine Priorität,
keine Deny-Regel. user_has_role_for_host() (:40-47) liest hosts.host_group_id in einer
Extra-Query und delegiert. is_admin umgeht RBAC überall.
Zwei gleichrangige Vergabewege: direkt pro Benutzer über user_hostgroup_roles
(0001:85-94; API app/admin/routes.py:1301-1380) und über Benutzergruppen via
group_hostgroup_roles (0004:25-35; API :1385-1465). Im UI stehen beide Formulare
optisch gleichwertig untereinander (templates/admin.html:515-546 und :548-576, Handler
static/js/admin.js:1077-1094 und :1096-1113).
Rechte gibt es ausschließlich auf Hostgruppen-Ebene. Es existiert keine Tabelle für
Rechte auf einzelne Server; host-bezogene Prüfungen sind reine Umleitungen über die
Hostgruppe. Ein Host liegt in genau einer Hostgruppe (hosts.host_group_id NOT NULL,
0001_initial.sql:49) — 1:n, keine n:m-Tabelle. Die Hostgruppe ist damit gleichzeitig
Ordnungs- und einzige Rechteeinheit; ein Host umzusortieren ändert schlagartig, wer ihn
erreicht.
Zugangsdaten haben heute keinerlei Berechtigungsebene. Die Zuordnung läuft ausschließlich
Host ↔ Credential: host_ssh_key_map (n:m), ssh_password_credentials (1 je Host),
host_rdp_credential_map (PK auf host_id, also 1 Set je Host). Die Auswahl im Proxy ist
benutzerblind: app/ssh_proxy/proxy.py:311-316 selektiert per LIMIT 1 ohne
ORDER BY und ohne Bezug zum anfragenden Benutzer; app/rdp_proxy/ws_tunnel.py:183-196
ebenso. Konsequenz: jeder Benutzer mit ssh_connect/rdp_connect auf der Hostgruppe meldet
sich zwangsläufig mit demselben, gemeinsam genutzten Zielkonto an. „Benutzer A darf sich als
svc-app anmelden, Benutzer B nur als readonly" ist im heutigen Datenmodell nicht
abbildbar. 0008_credentials_roles.sql führt keine Credential-Rechteebene ein, sondern
nur zwei weitere Rollen auf derselben Achse Hostgruppe. ssh_keys.owner_user_id (0001:70)
wäre der einzige Ansatz — die Spalte ist tot (kein Lese-/Schreibpfad; tests/test_phase13.py:233-258
verankert das sogar).
D.2 Schwachstellen des Ist-Zustands
| ID | Schwachstelle |
|---|---|
| S1 | Zwei gleichrangige Vergabepfade. Effektive Rechte sind nirgends an einer Stelle ablesbar; weder API noch UI kennen eine „Effektive Berechtigungen für Benutzer X"-Sicht. Ein Entzug über /admin/roles/revoke kann wirkungslos bleiben, weil die Gruppe dasselbe Recht weiterliefert — das UI meldet trotzdem Erfolg (admin.js:1041-1046). |
| S2 | Keine Deny-Regeln, keine Priorität. UNION = das permissivste Ergebnis gewinnt. „Team-Recht, aber für diesen einen Benutzer nicht" ist nicht modellierbar. |
| S3 | Keine Rechteebene für Zugangsdaten (D.1). Größte fachliche Lücke. |
| S4 | Nicht-Admin mit credentials_manage kann fremde Zugangsdaten anziehen. In app/admin/routes.py:1701-1708 und :1011-1020 wird die Zugehörigkeit nur für Admins geprüft; im Rollen-Pfad läuft nur eine Existenzprüfung. Ein Benutzer mit credentials_manage auf seiner Hostgruppe kann einen beliebigen fremden Schlüssel bzw. RDP-Satz auf einen seiner Hosts legen und sich regulär damit verbinden. Sicherheitslücke, unabhängig vom Umbau zu beheben. |
| S5 | Drei tote Rollen (D.1). |
| S6 | Protokoll-Lücke. app/catalog/routes.py:48, 56 filtert Rollennamen ohne Abgleich mit h.protocol und setzt can_connect pauschal (:31, 105). Siehe A.3/D1. |
| S7 | file_transfer wird doppelt geprüft (Rolle + hosts.file_transfer_enabled), clipboard nur host-global; RDP-Laufwerksumleitung ist gar nicht rechtebehaftet. |
| S8 | Löschen einer Benutzergruppe = stille Massen-Rechteentziehung. app/admin/routes.py:560 räumt via CASCADE user_group_members und group_hostgroup_roles ab — ohne Vorab-Zähler, ohne 409-Sperre (anders als bei Hostgruppen, :707-714), und das Audit-Event enthält nur die ID (:563). Nach dem Umbau ist die Gruppe der einzige Rechteträger, damit wiegt das doppelt. |
| S9 | Karteileichen nach Benutzer-Löschung. Regelfall ist Soft-Delete (:452-459), also kein CASCADE: Rollen- und Mitgliedschaftszeilen bleiben. GET /admin/roles (:1361-1372) joint users ohne deleted_at IS NULL und listet deleted_user_<id>-Vergaben. |
| S10 | Auditierbarkeit. Eine Mitgliedschaftsänderung ändert Zugriffsrechte, ohne dass das Ereignis das ausdrückt; INSERT OR REPLACE (:1315) überschreibt ein bestehendes expires_at, ohne den alten Wert festzuhalten. |
| S11 | Performance/Duplizierung. user_has_role_for_host = zwei Roundtrips; require_admin_scope_or_host_role (app/auth/deps.py:236-238) schleift mit je eigener Query; user_hostgroup_roles hat nur einen Index auf user_id (0001:94), group_hostgroup_roles dagegen auf beiden FK-Spalten (0004:34-35). |
| S12 | revoke_role (:1331-1344) prüft die Hostgruppe, aber nicht den Benutzer-Scope; revoke_group_role (:1415-1428) prüft die Benutzergruppe nicht. Beim Umbau nicht mitkopieren. |
| S13 | RBAC-Logik dreifach implementiert: app/rbac.py, app/catalog/routes.py:36-102 (baut die Vereinigung dreimal von Hand nach) und app/tenancy.py:90-92. Jede Modelländerung muss überall nachgezogen werden. |
D.3 Soll-Modell
Grundsatz: Ein Benutzer erhält Rechte nur über seine Mitgliedschaft in Benutzergruppen. Zwei Achsen:
- Achse A — Zugriff: Benutzergruppe × Hostgruppe × Rolle (bereits vorhanden,
group_hostgroup_roles). - Achse B — Zugangsdaten (neu): Benutzergruppe × Zugangsdatensatz. Beantwortet erstmals, womit sich eine Gruppe anmeldet.
Eine Verbindung kommt nur zustande, wenn ein Zugangsdatensatz existiert, der sowohl dem Host zugeordnet als auch einer Gruppe des Benutzers freigegeben ist.
Entfällt: user_hostgroup_roles als Schreibpfad (Tabelle nach Migration leeren bzw. als
*_legacy behalten — Droppen ist im Projekt ohnehin unüblich, vgl. 0010:9-14, 0012:12-17).
Ebenso die drei toten Rollen: entweder ausimplementieren oder aus roles und beiden
Whitelists streichen — eine bewusste Entscheidung je Rolle, kein Sammelbeschluss.
Bleibt unverändert: roles, host_groups, hosts, user_groups, user_group_members,
group_hostgroup_roles, ssh_keys, rdp_credentials, ssh_password_credentials,
host_ssh_key_map, host_rdp_credential_map.
Neu (Achse B): drei schmale Freigabetabellen — Gruppe × SSH-Key, Gruppe ×
RDP-Zugangsdatensatz, Gruppe × SSH-Passwort-Zugangsdaten. Aufbau analog zu
group_hostgroup_roles: zusammengesetzter PK, FK auf user_groups mit ON DELETE CASCADE,
FK auf das Credential-Objekt, dazu granted_by, granted_at, expires_at. Empfehlung:
ssh_password_credentials vorher analog zu Migration 0012 in ein eigenständiges,
wiederverwendbares Objekt mit eigener ID umbauen, damit alle drei Credential-Arten dieselbe
Form haben.
Kardinalität Host ↔ Hostgruppe: bei 1:n belassen. Eine n:m-Zuordnung würde die Prüfung erneut auf „irgendeine Gruppe" und damit auf die permissivste Auflösung zurückwerfen.
Prüf-Queries: user_has_role() schrumpft auf einen Zweig — der komplette UNION
samt Zweig A (app/rbac.py:18-24) entfällt. Die nötigen Indizes existieren bereits
(0004:23, 35). Neu hinzu kommen zwei Funktionen in app/rbac.py: eine Prüfung „darf dieser
Benutzer diesen Zugangsdatensatz nutzen" und eine Auflösung „welcher Satz gilt für diesen
Benutzer auf diesem Host". Letztere ersetzt die benutzerblinden LIMIT 1-Queries in
app/ssh_proxy/proxy.py:311-316 und app/rdp_proxy/ws_tunnel.py:183-187. Wichtig: eine
deterministische Auswahlregel festlegen. Bei mehreren Treffern lieber ein klarer Fehler
oder eine explizite Auswahl im Katalog als das heutige LIMIT 1 ohne ORDER BY.
app/catalog/routes.py:36-107 wird auf dieselbe Gruppenlogik reduziert, zusätzlich gegen
h.protocol abgeglichen (S6), und can_connect sollte künftig auch abbilden, ob überhaupt
ein für diesen Benutzer nutzbarer Zugangsdatensatz existiert. app/rbac.py wird die einzige
Wahrheitsquelle; catalog benutzt sie, statt die Logik nachzubauen (S13).
D.4 Migration bestehender Direktvergaben
Empfohlen: automatisch erzeugte persönliche Gruppe je betroffenem Benutzer, danach manuelle Konsolidierung. Das ist rechteerhaltend — niemand verliert oder gewinnt beim Umstieg etwas.
- Für jeden Benutzer mit mindestens einer Zeile in
user_hostgroup_roleseine Benutzergruppe anlegen. WegenUNIQUE(user_groups.name)und möglicher Umbenennungen ist ein eigenes Kennzeichen (Spalte „ist persönliche Gruppe") den ALTER-Aufwand wert — eine reine Namenskonvention reicht nicht. - Den Benutzer als einziges Mitglied eintragen.
- Alle seine Direktvergaben 1:1 nach
group_hostgroup_rolesübernehmen — inklusivegranted_by,granted_atundexpires_at, damit die Ablaufsemantik erhalten bleibt. Konflikte mit bereits bestehenden identischen Gruppenvergaben ignorieren. user_hostgroup_rolesleeren bzw. umbenennen.- Achse B rechteneutral vorbefüllen: für jeden Host mit zugeordnetem Zugangsdatensatz jede
Gruppe freigeben, die auf der Hostgruppe dieses Hosts
ssh_connectbzw.rdp_connecthat. Das reproduziert exakt das heutige Verhalten. - Admin-Report „persönliche Gruppen mit identischem Rechteprofil" als Grundlage für die Zusammenlegung zu echten Teams, danach restriktives Aufräumen der Vorbefüllung aus Schritt 5.
Die Alternative „rein manuelle Zuordnung" liefert ein saubereres Ergebnis, bedeutet aber ein Zeitfenster mit Rechteverlust und ist ab einer gewissen Benutzerzahl nicht praktikabel.
D.5 Auswirkungen auf API und UI
POST /admin/roles/grantund/admin/roles/revoke(app/admin/routes.py:1301-1353) entfallen. Übergangsweise mit HTTP 410 und Verweis auf/admin/group-roles/*antworten, damit bestehende Token-Integrationen einen sprechenden Fehler bekommen.GET /admin/roleswird zur abgeleiteten Sicht „effektive Rechte je Benutzer" — Join über Mitgliedschaft und Gruppenvergabe, mit Spalte „über welche Gruppe". Das schließt S1./admin/group-roles/*bleibt der einzige Schreibpfad; im Revoke-Pfad die fehlende Scope-/Existenzprüfung nachziehen (S12).- Neu: Endpunkte zum Freigeben und Entziehen von Zugangsdaten je Gruppe (Achse B) plus eine Leseansicht.
- Die Zuordnungs-Endpunkte für Host-Credentials bleiben, aber die Prüfung im Nicht-Admin-Zweig
(
:1011-1020,:1701-1708) wird von „existiert" auf „ist einer meiner Gruppen freigegeben" umgestellt — das behebt S4. POST /admin/user-groups/{id}/members(:584-625) wird der sicherheitskritischste Endpunkt des Systems und braucht ein Audit-Event, das die dadurch gewonnenen Rechte mitschreibt (S10).- UI: Formular und Tabelle der Direktvergabe (
templates/admin.html:517-546,static/js/admin.js:1077-1094) entfernen; die Gruppenvergabe rückt an die erste Stelle; die Rollentabelle bleibt als nicht editierbare Effektiv-Ansicht mit Spalte „Gruppe". Neuer Block „Zugangsdaten für Gruppe freigeben". Im Benutzer-Tab pro Benutzer Mitgliedschaften und die daraus abgeleiteten Rechte anzeigen — das ist die neue Antwort auf „warum kommt X auf Server Y".ROLE_NAMES(static/js/admin.js:4-7) von der Hartkodierung aufGET /admin/roles/namesumstellen. Hinweistexttemplates/admin.html:117undJumphost_Konzept.md:132-135nachziehen.
D.6 Reihenfolge
- Vorarbeit, rechteneutral: tote Rollen entscheiden;
cataloggegenh.protocolabsichern (S6); Scope-Lücken S4 und S12 schließen;app/rbac.pyals einzige Wahrheitsquelle etablieren undcatalogdarauf umstellen (S13). - Teil C (Mandanten-Rückbau) muss abgeschlossen sein —
rdp_credentials.tenant_idistNOT NULLund würde sonst in die neuen Freigabetabellen hineinregieren. - Migration: persönliche Gruppen erzeugen, Direktvergaben spiegeln, Achse-B-Tabellen anlegen und rechteneutral vorbefüllen (D.4, Schritte 1–3 und 5).
- Lesepfade umstellen:
rbac.pyauf einen Zweig,catalogauf Gruppenlogik, beide Proxies auf die neue Credential-Auflösung. Ab hier ist Achse B scharf. - Schreibpfade abschalten: Direktvergabe-Endpunkte auf 410, UI-Formular entfernen,
user_hostgroup_rolesleeren/umbenennen. - Konsolidierung: Report über persönliche Gruppen, Zusammenlegung zu Teams, restriktives Aufräumen der Achse-B-Vorbefüllung.
- Tests:
tests/test_rbac.pyschreibt heute direkt inuser_hostgroup_roles(:27-29) und muss vollständig neu geschrieben werden. Ebenfalls betroffen:tests/test_phase9.py:210, 261, 290,tests/test_phase13.py:201,tests/test_tenants.py:347-393,tests/test_admin_groups_tokens.py:87-145.
D.7 Risiken
| Risiko | Ursache | Gegenmaßnahme |
|---|---|---|
| Rechteverlust beim Abschalten der Direktvergabe | Migration übersieht ablaufende Grants oder Metadaten | Schritt 3 vor Schritt 5; expires_at 1:1 übernehmen; Vorher/Nachher-Diff der effektiven Rechte je Benutzer als verpflichtende Migrationsprüfung |
| Rechteausweitung über persönliche Gruppen | eine persönliche Gruppe bekommt später weitere Mitglieder | Kennzeichen „persönlich" plus serverseitige Sperre „max. 1 Mitglied", bis ein Admin sie bewusst in eine echte Gruppe überführt |
| Rechteausweitung durch die Achse-B-Vorbefüllung | Schritt 5 gibt jeder verbindungsberechtigten Gruppe alle Host-Credentials frei | Gegenüber heute bewusst rechteneutral (heute nutzen ohnehin alle dasselbe Konto), aber sofort als Aufräum-Backlog dokumentieren — sonst zementiert die Migration den Ist-Zustand |
| Zugriffsausfall durch Achse B | nach Schritt 4 scheitert jede Verbindung, für die keine Freigabe existiert | Vorab-Report „Hosts mit Zugangsdaten ohne Gruppenfreigabe"; Schritt 4 erst nach dessen Bereinigung |
| Stille Massenentziehung | Gruppenlöschung wird zum einzigen Rechteträger und cascadet zwei Tabellen (S8) | 409-Sperre analog Hostgruppen; Audit-Event mit Vollinhalt der entfernten Vergaben |
| Karteileichen | Soft-Delete räumt Mitgliedschaften nicht ab (S9) | beim Soft-Delete Mitgliedschaften explizit entfernen; deleted_at IS NULL in alle Rollenlisten aufnehmen |
| Verhaltensänderung bei der Credential-Auswahl | LIMIT 1 ohne ORDER BY wird durch benutzerabhängige Auswahl ersetzt |
Mehrdeutigkeit hart als Fehler behandeln; vorab prüfen, welche Hosts mehr als einen Key in host_ssh_key_map haben |
| Notfallzugang | ohne Direktvergabe braucht eine dringende Einzelfreigabe eine Gruppe | bewusst entscheiden und dokumentieren; ggf. eine „Break-Glass"-Gruppe mit befristeter Mitgliedschaft — dafür wäre ein Ablaufdatum an der Mitgliedschaft neu, das gibt es heute nicht |
TEIL E — Mehrere Benutzer gleichzeitig
Der Nutzer verlangt ausdrücklich: „es muss sichergestellt sein, dass mehrere Benutzer gleichzeitig eine Session offen haben können". Das ist keine neue Funktion — es gibt keine Stelle im Code, die eine zweite gleichzeitige Anmeldung oder eine zweite Sitzung verbietet. Die Arbeit liegt woanders: die Anwendung ist an mehreren Stellen so gebaut, dass sie unter Parallelbetrieb entweder alle Benutzer gleichzeitig ausbremst oder Daten beschädigt. Ein Zweitbenutzer merkt heute nichts von einer Sperre — er merkt, dass sein Terminal stockt, während jemand anders eine Datei hochlädt.
E.0 Die eine Rahmenbedingung, aus der fast alles folgt
Die Anwendung läuft in einem uvicorn-Prozess, mit einem Event-Loop
(ansible/roles/jumphost_app/templates/jumphost-app.service.j2:28-32 — kein --workers) und
einer globalen SQLite-Verbindung (app/db.py:20, :29, isolation_level=None). Alle
Sitzungen aller Benutzer, die Admin-UI und jeder API-Aufruf teilen sich diesen einen Loop.
Daraus folgt die Faustregel für Teil E: jede synchrone Operation im Request-/Session-Pfad ist eine Sperre für sämtliche Benutzer. Nicht „langsam für den Verursacher" — steht für alle. Das ist auch der Grund, warum A1 (byteweises Lesen von guacd) in dieser Liste mitzählt: eine einzige RDP-Sitzung blockiert damit die SSH-Sitzungen aller anderen.
Der zweite Teil der Rahmenbedingung: prozesslokaler Zustand. Die Registry laufender
Sitzungen (app/security/active_sessions.py:40), der Ringpuffer des Live-Verbindungslogs
(app/security/log_stream.py) und der Login-Rate-Limiter
(app/security/rate_limit.py:19) liegen im Prozessspeicher. Das ist bei einem Prozess
korrekt und in den Modulen sauber dokumentiert — es heißt aber auch: die naheliegende
„Lösung" für Parallelität, nämlich --workers 4, ist keine. Sie würde „Sitzung beenden",
die Live-Mitschau und das Rate-Limit still zerlegen. Siehe E.4.
E.1 Sofort — die zwei Punkte, die heute schon Schaden anrichten
| ID | Fundstelle | Problem | Auswirkung |
|---|---|---|---|
| E1 | app/security/audit.py:41-53 |
Zwischen SELECT entry_hash ... ORDER BY id DESC LIMIT 1 und dem INSERT liegen mehrere await. Es gibt weder eine Transaktion (app/db.py:29 setzt isolation_level=None, jede Anweisung committet einzeln — die conn.commit()-Aufrufe an den Aufrufstellen sind wirkungslos) noch eine Sperre. |
Race Condition in der Audit-Hash-Kette. Zwei gleichzeitig startende oder endende Sitzungen lesen denselben prev_hash und schreiben beide darauf. verify_chain() (:56-70) meldet die Kette ab dieser Stelle als gebrochen — und zwar dauerhaft, denn DB-Trigger verbieten UPDATE/DELETE auf audit_log. Der Nachweis der Unverfälschtheit, also der Zweck des ganzen Konstrukts, ist damit im Mehrbenutzerbetrieb zufällig. Zu tun: Lesen+Schreiben atomar machen (prozessweite asyncio.Lock und BEGIN IMMEDIATE), plus ein Test, der n Ereignisse parallel schreibt und danach verify_chain() prüft. |
| E7 | app/main.py:51-60 (lifespan ruft nur init_db()) |
Kein Reaper beim Start: Sitzungen, die beim Absturz/Neustart offen waren, behalten ended_at IS NULL für immer. |
Nach jedem Neustart wachsen die „laufenden" Sitzungen an. Die Sessionview (app/admin/routes.py:1897-1940) zeigt sie als aktiv, aber nicht beendbar (killable: false), weil kein Task mehr existiert. Jede künftige Zählung „wie viele Sitzungen hat dieser Benutzer offen" (E6, Teil F) rechnet mit Geistern. Zu tun: beim Start alle offenen Sitzungen mit end_reason='server_restart' schließen und ein Audit-Ereignis mit der Anzahl schreiben. |
Diese beiden Punkte sind unabhängig von allem anderen und gehören vor Teil C/D erledigt.
E.2 Blockierende Operationen im Event-Loop
| ID | Fundstelle | Problem | Auswirkung |
|---|---|---|---|
| E2 | app/recordings/recorder.py:27 (open), :40-41 (write + flush je Instruktion) |
Synchrone Dateizugriffe direkt im Event-Loop, ein flush() pro Frame. |
Bei RDP entspricht das dem kompletten Bildstrom (siehe D2 in Teil A). Jeder Frame jeder Sitzung hält den Loop an — für alle anderen Benutzer. Zu tun: puffern, Schreiben in einen Executor/Task auslagern, Größenbegrenzung und Rotation (D2). Die Hash-Verkettung der Aufzeichnung muss dabei erhalten bleiben. |
| E3 | app/ssh_proxy/sftp.py:87 → app/security/av_scan.py (subprocess.run(..., timeout=30)) |
Der AV-Scan läuft synchron in einer async-Route. |
Bis zu 30 Sekunden, in denen kein anderer Benutzer eine Taste absetzen kann, kein Bild ankommt und kein Login durchgeht. Zu tun: in einen Thread auslagern (asyncio.to_thread/run_in_threadpool). |
| E4 | app/ssh_proxy/sftp.py:83 (file.read(MAX_UPLOAD_BYTES + 1)), :98 (sha256 über alles), :163 (remote_file.read() komplett), :193-195 |
Uploads und Downloads werden vollständig im Arbeitsspeicher gehalten, 200 MiB pro Transfer. | Drei parallele Transfers = 600 MiB RSS zusätzlich, plus Hashing im Loop. Auf einer typischen VM ist das der OOM-Killer für den gesamten Dienst — alle Sitzungen weg. Zu tun: strömend lesen/schreiben, Hash inkrementell, Transfergröße und gleichzeitige Transfers begrenzen. |
| E5 | app/db.py:20, :29 |
Eine einzige Verbindung für Lese- und Schreibzugriffe; alle Abfragen serialisieren über deren Thread. | Eine unglückliche Admin-Abfrage verzögert jeden WebSocket-Handshake. Die Pagination ist immerhin gedeckelt (app/admin/routes.py:1847-1857, :1899-1917, limit ≤ 1000) — beim Rückbau (Teil C/D) darf keine neue ungebremste Abfrage entstehen. Prüfen, ob PRAGMA synchronous und busy_timeout (:32, 5000 ms) für den Zielbetrieb passen; die WAL-Konfiguration selbst ist richtig. |
E.3 Trennung zwischen Benutzern
| ID | Fundstelle | Problem | Auswirkung |
|---|---|---|---|
| E6 | app/rdp_proxy/guacd_client.py:222-224 (drive-path: /var/lib/jumphost/rdp-drives/{host_id}) |
Das umgeleitete Laufwerk ist pro Host angelegt, nicht pro Sitzung oder Benutzer. | Zwei Benutzer auf demselben Windows-Ziel teilen sich ein Verzeichnis: A sieht die Dateien von B, überschreibt sie, liest sie mit. Das ist im Mehrbenutzerbetrieb ein Vertraulichkeitsproblem, kein Komfortthema — und es gibt keine Aufräumung. Zu tun: Pfad je Sitzung (oder mindestens je Benutzer und Host), beim Sitzungsende entfernen; zusammen mit Befund B6 lösen (/var/lib/jumphost ist 0700 jumphost:jumphost, ansible/roles/sqlite_init/tasks/main.yml:6-12, guacd läuft als guacd — heute kommt guacd gar nicht erst in das Verzeichnis hinein). |
| E8 | app/security/rate_limit.py:32-34 (10 Logins/Minute je Quell-IP), ansible/roles/nginx_proxy/templates/jumphost.conf.j2:12 + :38 (10r/m je IP), ansible/roles/fail2ban/tasks/main.yml (maxretry = 8, findtime = 300, bantime = 1800) |
Alle drei Bremsen greifen pro Quell-IP. | Sitzen zehn Benutzer hinter einer NAT-Adresse — der Normalfall in einem Firmennetz —, teilen sie sich ein Budget von 10 Anmeldeversuchen pro Minute, und acht Tippfehler beliebiger Kollegen sperren die ganze Firma für 30 Minuten aus. Genau das kollidiert mit der Anforderung „mehrere Benutzer gleichzeitig". Zu tun: die kontenbezogene Sperre (app/auth/routes.py, max_failed_logins) bleibt die scharfe Bremse; das IP-Budget an die erwartete Benutzerzahl anpassen bzw. für bekannte interne Netze anheben, und die fail2ban-Schwelle entsprechend. Bewusst entscheiden und im Betriebshandbuch festhalten. |
| E9 | app/auth/deps.py:70-79 gegen :85-105 |
Der gleitende Idle-Timeout wird nur bei HTTP-Anfragen verlängert; der WebSocket-Handshake tut das nicht (kann er auch nicht). settings.session_idle_timeout_s steht auf 900 s. |
Ein Benutzer, der eine Stunde ausschließlich im Terminal arbeitet, hat ein abgelaufenes Cookie: die laufende Sitzung überlebt, aber jede weitere Aktion — zweite Sitzung öffnen, Dateitransfer, Katalog laden — endet mit „Session abgelaufen". Mit mehreren gleichzeitigen Sitzungen fällt das ständig auf. Zu tun: leichter Keepalive aus der Sitzungsoberfläche auf einen billigen HTTP-Endpunkt, oder Auffrischung von last_seen durch WS-Aktivität. Voraussetzung für Teil F. |
| E10 | app/ssh_proxy/terminal_ws.py:39 + :147 (IDLE_TIMEOUT_SECONDS = 15 * 60 auf websocket.receive_json()) |
Der Timeout misst nur Eingaben des Benutzers, nicht Aktivität der Sitzung. | Ein laufendes tail -f, ein langer Build, eine Sitzung im Hintergrundtab: nach 15 Minuten ohne Tastendruck getrennt. Im Mehrsitzungsbetrieb (Teil F) trifft das systematisch jede nicht gerade sichtbare Sitzung. Zu tun: Inaktivität in beiden Richtungen messen, Wert konfigurierbar machen, und den Grund als Klartext senden (Teil B). |
| E11 | app/ssh_proxy/terminal_ws.py:38 (MAX_SESSION_SECONDS = 8 * 3600) |
Die Konstante wird nirgends verwendet. | Es gibt keine absolute Obergrenze für die Dauer einer Sitzung — anders als beim Anmelde-Cookie (app/config.py:72). Zu tun: entweder umsetzen (mit Vorwarnung an den Benutzer) oder die Konstante entfernen; nichts ist schlimmer als ein Sicherheitsversprechen, das nur als toter Bezeichner existiert. |
E.4 Obergrenzen und Ressourcen
Heute gibt es keine Grenze für die Zahl gleichzeitiger Sitzungen — weder je Benutzer noch global. Solange Sitzungen in eigenen Browser-Tabs geöffnet werden, fällt das nicht auf; mit der Seitenleiste aus Teil F wird es zur Normalität, dass ein Benutzer fünf Sitzungen hält. Fehlende Grenzen an drei Stellen:
- Anwendung: kein Zähler je Benutzer, kein globaler Zähler. Einzuführen als konfigurierbare Werte, mit einer Ablehnung im Klartext („Sie haben bereits N Sitzungen offen") statt eines wortlosen Verbindungsendes — Teil B gilt auch hier.
- uvicorn: kein
--limit-concurrency, kein--backlog(jumphost-app.service.j2:28-32). - systemd: kein
LimitNOFILEin der Unit. Jede Sitzung kostet mindestens zwei Sockets (Browser-WS, Ziel), RDP zusätzlich die guacd-Verbindung; dazu die Aufzeichnungsdatei. Der Standardwert reicht für den geplanten Betrieb, aber der Wert gehört gesetzt und dokumentiert, statt geerbt zu werden.
Ebenfalls zu klären: guacd startet je RDP-Sitzung einen eigenen Prozess. Speicher- und CPU-Bedarf je gleichzeitiger RDP-Sitzung einmal messen (Auflösung, Farbtiefe) und daraus die Obergrenze ableiten — nicht schätzen.
E.5 Was ausdrücklich NICHT getan werden soll
Keine zusätzlichen uvicorn-Worker. Das ist die naheliegende, falsche Antwort auf „mehrere Benutzer". Sie würde drei Funktionen still beschädigen:
- „Sitzung beenden" und die Live-Mitschau wirken nur innerhalb des eigenen Prozesses
(
app/security/active_sessions.py:12-26,app/admin/routes.py:1920-1940) — ein Superadmin könnte fremde Sitzungen weder sehen noch beenden. - Das Login-Rate-Limit (
app/security/rate_limit.py:4-7) würde sich mit der Zahl der Worker multiplizieren. - Das Live-Verbindungslog zeigte nur die Meldungen eines Workers.
Der Weg zu mehr Gleichzeitigkeit führt über E1–E11 (nichts Blockierendes im Loop), nicht über mehr Prozesse. Sollte der Betrieb später doch mehrere Prozesse verlangen, ist das ein eigenes Vorhaben mit einem gemeinsamen Zustandsspeicher für genau diese drei Dinge.
E.6 Abnahmekriterium für Teil E
Ein reproduzierbarer Lasttest gehört zum Ergebnis, nicht ein „läuft bei mir":
- Drei Benutzer, gleichzeitig angemeldet: zwei SSH-Sitzungen und eine RDP-Sitzung parallel.
- Während die RDP-Sitzung Vollbildinhalte überträgt, muss die Eingabe in beiden SSH-Sitzungen ohne wahrnehmbare Verzögerung ankommen (Zielwert festlegen und messen, z. B. Echo-Zeit < 150 ms im 95. Perzentil).
- Während eines 100-MiB-Uploads mit aktivem AV-Scan bleiben alle Sitzungen bedienbar.
- Nach dem Test:
verify_chain()über das Audit-Log ist intakt,sessionsenthält keine Zeile ohneended_at, deren Task nicht mehr existiert. - Neustart des Dienstes mit drei offenen Sitzungen: danach steht keine dieser Sitzungen mehr als „aktiv" in der Sessionview.
TEIL F — Mehrere Sitzungen je Benutzer mit Seitenleiste
Anforderung des Nutzers: „ein Benutzer kann mehrere Sessions offen haben, mit z. B. einer Seitenleiste zum Switchen der offenen Session". Technisch ist das heute bereits möglich — nur eben als Stapel von Browser-Tabs, ohne Übersicht, ohne gemeinsamen Rahmen, und mit einem Server, der von diesen Sitzungen nichts weiß, was er dem Benutzer zeigen könnte.
Teil F setzt Teil E voraus. Eine Oberfläche, die fünf Sitzungen gleichzeitig sichtbar hält, auf einem Event-Loop, der schon bei zwei RDP-Sitzungen einbricht, wäre eine Verschlechterung gegenüber dem heutigen Zustand.
F.1 Ist-Zustand
- Jede Sitzung ist eine eigene Seite:
GET /terminal/{host_id}bzw.GET /rdp/{host_id}(app/main.py:126-133), gerendert austemplates/terminal.html/templates/rdp.html. - Das Dashboard öffnet sie in einem neuen Tab:
static/js/dashboard.js:54-62(connect.target = "_blank"). - Beide Sitzungsskripte sind Singletons auf Dokumentebene: sie greifen über feste IDs auf
genau ein Element zu (
static/js/terminal.js:18-30,static/js/rdp.js:14-17), hängen Tastatur, Zwischenablage und Fenstergröße global ans Dokument (rdp.js:78-89,terminal.js:113) und beenden sich perwindow.close()(rdp.js:111-120, analog interminal.js). - Der Server kennt zwar alle Sitzungen (
sessions-Tabelle,app/security/active_sessions.py), stellt sie aber nur Superadmins zur Verfügung (app/admin/routes.py:1897). Ein normaler Benutzer hat keine Möglichkeit zu erfahren, welche Sitzungen er selbst offen hat. - Es gibt keinen Wiederanschluss: die WebSocket-Verbindung ist die Sitzung. Der
finally-Block inapp/ssh_proxy/terminal_ws.py:223-246beendet SSH-Verbindung, Aufzeichnung und Datenbankzeile, sobald das Socket schließt. Ein Seiten-Reload beendet die Sitzung und beginnt eine neue.
F.2 Sollbild
Eine dauerhafte Arbeitsfläche (Vorschlag: GET /workspace), die den Rahmen stellt und die
Sitzungen darin hält:
- Seitenleiste mit einer Kachel je offener Sitzung: Hostname, Protokollsymbol, Statuspunkt (verbinde / verbunden / getrennt / Fehler), Laufzeit, Schließen-Knopf.
- Umschalten ohne Verbindungsabbruch — der wichtigste Punkt überhaupt. Die inaktive Sitzung wird ausgeblendet, nicht abgebaut.
- „Neue Sitzung" öffnet den Hostkatalog (
GET /catalog/hosts) direkt in der Arbeitsfläche. - Sammelzustand: sichtbare Kennzeichnung, wenn eine Hintergrundsitzung einen Fehler oder eine Trennung meldet — sonst bemerkt der Benutzer es erst beim Umschalten.
- Die Werkzeugleiste (Vollbild, Dateitransfer, Strg+Alt+Entf, Exit) gehört je Sitzung, nicht global.
- Das Dashboard bleibt als Einstieg erhalten; „Verbinden" führt künftig in die Arbeitsfläche
statt in einen neuen Tab. Der bisherige Direkteinstieg über
/terminal/{id}und/rdp/{id}sollte weiterbestehen (Lesezeichen, Fehlersuche) — dann aber als dünner Rahmen um denselben Sitzungsbaustein, nicht als zweite Implementierung.
F.3 Was das technisch bedeutet
Das ist der eigentliche Umbau. Die Reihenfolge ist bewusst so gewählt, dass jede Stufe für sich lauffähig bleibt.
- Aus Skripten werden Bausteine.
terminal.jsundrdp.jsmüssen von „ein Skript pro Seite" zu „eine Instanz pro Sitzung, in einem übergebenen Container" werden — mitconnect(),dispose(),setActive(bool)und einem Statusrückruf für die Seitenleiste. Feste Element-IDs (session-shell,status,terminal,rdp-display,fullscreen-btn,ft-*intemplates/terminal.html:11-47) sind dabei durch instanzbezogene Referenzen zu ersetzen. Das ist der größte Einzelposten in Teil F. - Tastatur und Zwischenablage dürfen nur die aktive Sitzung erreichen.
new Guacamole.Keyboard(document)(rdp.js:78) und derpaste-Listener am Dokument (:82) würden sonst Eingaben an alle offenen RDP-Sitzungen gleichzeitig schicken — ein Tastendruck landet auf drei Windows-Servern. Ebensowindow.addEventListener("resize")interminal.js:113. Beim Umschalten müssen gedrückte Modifiertasten zurückgesetzt werden (sonst bleibt Strg/Alt in der verlassenen Sitzung „hängen"). - Sichtbarwerden heißt neu einpassen. Eine ausgeblendete Sitzung hat Größe 0. Beim
Umschalten muss xterm.js neu eingepasst und die neue Größe gesendet werden
(
terminal.js:107-112), und bei RDPclient.sendSize()— das existiert bis heute überhaupt nicht (Befund D4 in Teil A). F.3.3 setzt D4 voraus. - Hintergrundsitzungen dürfen nicht sterben. Siehe E10: der Idle-Timeout misst nur Tastatureingaben. Ohne die Korrektur aus Teil E trennt der Server jede Sitzung, die 15 Minuten lang nur im Hintergrund lag — die Seitenleiste wäre eine Liste toter Kacheln.
- Das Anmelde-Cookie muss am Leben bleiben. Siehe E9: eine Arbeitsfläche, die nur WebSockets spricht, lässt das Cookie nach 15 Minuten verfallen; „Neue Sitzung" scheitert dann mit „Session abgelaufen", während drei Sitzungen sichtbar laufen.
- Eine eigene API für die eigenen Sitzungen. Neu, im Katalog-Router (nicht im
Admin-Router!): eine Liste der eigenen offenen Sitzungen und ein Endpunkt zum Beenden einer
eigenen Sitzung. Zwingend auf
user_id = <eigene>eingeschränkt —GET /admin/sessions(app/admin/routes.py:1897-1940) darf dafür nicht geöffnet werden, es zeigt fremde Sitzungen, Benutzernamen und Aufzeichnungspfade. Für die Abfrage „eigene, noch offene Sitzungen" empfiehlt sich ein passender Index (sessions(user_id, ended_at); heute gibt es nuridx_sessions_user,app/db/migrations/0001_initial.sql:107). - Ehrlich bleiben beim Reload. Eine Kachel darf nicht suggerieren, dass eine Sitzung einen Seiten-Reload überlebt — sie tut es nicht (F.1, letzter Punkt). Entweder die Oberfläche sagt das klar („Sitzung wurde beendet, erneut verbinden"), oder es wird ein eigenes Vorhaben daraus (serverseitiges Halten der SSH-Verbindung samt Ausgabepuffer über eine Trennung hinweg). Das ist kein Nebenbei-Feature und gehört nicht in Teil F hineingeschmuggelt. Empfehlung: Stufe 1 ohne Wiederanschluss, klar beschriftet.
- Obergrenze auch in der Oberfläche. Die Grenze aus E.4 muss sichtbar sein: die Schaltfläche „Neue Sitzung" wird deaktiviert und begründet, statt eine Verbindung zu öffnen, die der Server dann ablehnt.
- Ressourcen im Browser. Jede RDP-Kachel ist ein eigener Canvas mit laufendem Bildstrom; der Server sendet auch an unsichtbare Sitzungen weiter. Zu entscheiden und zu dokumentieren: bleiben Hintergrundsitzungen voll verbunden (einfach, teuer) oder werden sie nach einer Weile in einen Halte-/Pausenzustand versetzt (sparsam, aber neuer Zustand im Protokoll). Empfehlung für Stufe 1: voll verbunden bleiben, mit niedriger Obergrenze.
- Audit und Aufzeichnung bleiben unverändert pro Sitzung. Jede Kachel ist weiterhin eine
eigene
sessions-Zeile mit eigener Aufzeichnung. Es darf keine „Sammelsitzung" der Arbeitsfläche entstehen — die Nachvollziehbarkeit je Zielsystem ist der Kern des Konzepts. - Keine neuen externen Abhängigkeiten. Die CSP erlaubt ausschließlich
'self'(app/main.py:87-96), Vendor-Bibliotheken werden lokal gebaut (ansible/roles/frontend_assets). Die Arbeitsfläche wird mit dem bestehenden Bestand gebaut — kein Framework, kein CDN.
F.4 Vorgeschlagener Schnitt
| Stufe | Inhalt | Danach lauffähig? |
|---|---|---|
| F1 | terminal.js/rdp.js zu instanzierbaren Bausteinen umbauen, bestehende Seiten /terminal/{id} und /rdp/{id} auf diese Bausteine umstellen |
ja — Verhalten unverändert, reiner Umbau |
| F2 | API für eigene Sitzungen (F.3.6) + Index | ja |
| F3 | Arbeitsfläche /workspace mit Seitenleiste, eine Sitzung gleichzeitig |
ja |
| F4 | Mehrere gleichzeitige Sitzungen, Umschalten, Fokus-/Tastaturtrennung (F.3.2, F.3.3) | ja |
| F5 | Obergrenzen, Statusanzeige, Sammelfehler, Feinschliff | ja |
Nach F1 lohnt ein Zwischenhalt: dieser Umbau ist die Stelle, an der sich die bisherigen RDP-Korrekturen aus Teil A bewähren müssen.
F.5 Risiken
| Risiko | Ursache | Gegenmaßnahme |
|---|---|---|
| Eingaben landen in der falschen Sitzung | dokumentweite Tastatur-/Paste-Listener (F.3.2) | Fokusmodell zuerst bauen, dann erst mehrere Sitzungen zulassen; expliziter Test mit zwei RDP-Sitzungen |
| Hintergrundsitzungen brechen ab | E10, E9 | Teil E vorher abschließen; Statuspunkt je Kachel macht Abbrüche sofort sichtbar |
| Browser wird langsam/instabil | mehrere Bildströme gleichzeitig (F.3.9) | niedrige Obergrenze in Stufe 1, gemessen statt geschätzt |
| Verlust der Nachvollziehbarkeit | Versuch, mehrere Ziele über eine Sitzung zu führen | F.3.10 als harte Regel; Abnahme prüft eine sessions-Zeile je Kachel |
| Rechteumgehung über die neue Sitzungs-API | Wiederverwendung des Admin-Endpunkts | eigener Endpunkt im Katalog-Router mit fester Einschränkung auf den eigenen Benutzer; Test mit fremder session_id |
F.6 Abnahmekriterium für Teil F
Ein Benutzer öffnet vier Sitzungen (zwei SSH, zwei RDP) in der Arbeitsfläche, schaltet
mehrfach zwischen ihnen um und lässt sie 30 Minuten offen. Danach gilt: alle vier sind noch
verbunden, in jeder ist die zuletzt gesehene Ausgabe vollständig vorhanden, Eingaben landen
ausschließlich in der sichtbaren Sitzung, die Größe stimmt nach jedem Umschalten, es
existieren genau vier sessions-Zeilen mit vier Aufzeichnungen, und ein zweiter Benutzer
arbeitet währenddessen unbeeinträchtigt.
Offene Entscheidungen für den Betreiber
Diese acht Punkte sind keine Implementierungsdetails und müssen vor der jeweiligen Phase beantwortet werden:
- guacd-Bezugsquelle auf Debian 12 — Quellcode-Build 1.5.5 gegen
freerdp2-dev(heutiger Rollen-Default, funktioniert auf bookworm) oder Ausweichen auf Ubuntu bzw. einen Container, um den Build ganz zu vermeiden (Teil A.2). Ein Debian-Paket ist keine Option:guacamole-serverist seit 2022 nicht mehr in Debian. - Umgang mit bestehenden Mandanten-Admins — Beförderung zu Voll-Admin oder Degradierung (Teil C.4/C.6, Phase 0.2). Ohne Antwort darf Teil C nicht starten.
users.home_tenant_id— echter Tabellen-Rebuild oder tote Spalte, mit der Folge, dasstenantsals leere Tabelle stehen bleibt (Teil C.5).- Bestehende API-Tokens — pauschal widerrufen (empfohlen) oder weiterlaufen lassen (Teil C.6, Phase 0.4).
- Die drei toten Rollen — je Rolle ausimplementieren oder entfernen (Teil D.3).
- Obergrenze gleichzeitiger Sitzungen — je Benutzer und global (Teil E.4). Ohne Zahl kann Teil F die Grenze weder durchsetzen noch anzeigen.
- Verhalten von Hintergrundsitzungen — voll verbunden lassen oder nach einer Frist in einen Pausenzustand versetzen (Teil F.3.9).
- Wiederanschluss nach Seiten-Reload — als eigenes Vorhaben planen oder bewusst ausschließen und in der Oberfläche so benennen (Teil F.3.7).
Abnahmekriterien
- Teil A: RDP-Sitzung zu einem Windows-Ziel baut auf und zeigt ein Bild; Fensterwechsel und Scrollen bleiben flüssig; parallel laufende SSH-Sitzungen werden dabei nicht langsamer.
- Teil B: die drei Störfälle aus B.3 liefern in Log und Browser unterscheidbare Klartextmeldungen mit Adresse, Schritt und Korrelations-ID.
- Teil C:
grep -ri tenantfindet außerhalb vonapp/db/migrations/0006*,0012*und den historischen Audit-Einträgen nichts mehr;PRAGMA foreign_key_checkist leer; die Audit-Hash-Kette ist intakt. - Teil E: der Lasttest aus E.6 läuft durch; die Audit-Kette bleibt unter Parallellast intakt; nach einem Neustart existiert keine Geistersitzung.
- Teil F: das Szenario aus F.6 läuft durch; Eingaben landen nachweislich nur in der
sichtbaren Sitzung; je Kachel genau eine
sessions-Zeile mit Aufzeichnung. - Teil D: kein Schreibpfad mehr auf
user_hostgroup_roles; der Vorher/Nachher-Diff der effektiven Rechte je Benutzer ist leer; für jeden Host mit Zugangsdaten existiert mindestens eine Gruppenfreigabe; die Testsuite läuft vollständig grün.