Files
ssh-jumphost/Umsetzungsauftrag_Sonnet5.md
2026-09-02 20:30:44 +02:00

77 KiB
Raw Permalink Blame History

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

  1. 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.
  2. 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.
  3. 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.
  4. Das Mandanten-Konzept (Tenants) wird vollständig entfernt. Es bringt für diese Anwendung keinen Nutzen und verteuert jede weitere Änderung.
  5. 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_id ist heute NOT NULL und 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 über schema_migrations Buch. Neue Schritte bekommen neue Nummern ab 0013.
  • 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/-shm bei 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:

  1. systemctl status guacd und journalctl -u guacd -n 50
  2. ss -ltnp | grep 4822
  3. ls /usr/lib/*/libguac-client-rdp.so* /usr/local/lib/libguac-client-rdp.so*
  4. nft list ruleset | grep daddr
  5. 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 Kommentarblock tasks/main.yml:1-42 und defaults/main.yml:1-26 auf den neuen Zielstand eindampfen, damit niemand später wieder nach einem Debian-Paket sucht.
  • Expliziter Start-/Enable-Task plus wait_for auf den konfigurierten Port mit sprechender Fehlermeldung.
  • Prüfung, dass das RDP-Plugin gebaut und installiert wurde (libguac-client-rdp.so im Bibliothekspfad), sonst harter Abbruch mit Klartext — sonst meldet guacd erst zur Laufzeit Support for protocol "rdp" is not installed. Der Build überspringt das RDP-Plugin stillschweigend, wenn die FreeRDP-Header fehlen; genau dieser Fall muss auffallen.
  • --sysconfdir und 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:

  1. guacd gestoppt → „guacd nicht erreichbar", mit Adresse und Hinweis auf den Dienst.
  2. Zielhost nicht erreichbar/geblockt → „Ziel <host:port> nicht erreichbar", nicht „Server timeout".
  3. 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_keys muss für den Rebuild aus sein. app/db.py:31 setzt sie beim Verbindungsaufbau auf ON, und _apply_migrations() (:41-56) fährt jede Migration per executescript() auf derselben Verbindung. Die Migration muss die PRAGMA selbst setzen und am Ende zurücksetzen, ohne sich auf ein umschließendes BEGIN zu verlassen. Danach PRAGMA foreign_key_check.
  • DROP TABLE tenants erst ganz zuletzt, nachdem alle FK-Klauseln verschwunden sind.
  • Beim Rename-Schritt PRAGMA legacy_alter_table beachten — seit SQLite 3.25 schreibt ALTER TABLE … RENAME TO Referenzen in anderen Tabellen mit um, was beim 12-Schritte-Verfahren unerwünscht ist (betrifft besonders ssh_keys, rdp_credentials, host_groups, user_groups).
  • rdp_credentials.tenant_id ist NOT NULL — der Code darf dort nicht vor dem Schema aufhören zu schreiben.
  • Audit-Kette: audit_log hat keine Tenant-Spalte, enthält aber historische Events tenant_created/tenant_updated/tenant_deleted/tenant_admin_granted/…_revoked. Diese Zeilen dürfen nicht angefasst werden — die Trigger no_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

  1. Ist-Erhebung auf der Produktiv-DB: Anzahl tenants, Anzahl tenant_admins, DISTINCT tenant_id je betroffener Tabelle, Benutzer mit home_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.
  2. Entscheidung zu tenant_admins: Beförderung zu is_admin = 1 oder Degradierung zum normalen Benutzer. Ohne diese Entscheidung darf Phase 1 nicht starten (C.4).
  3. Entscheidung zu users.home_tenant_id: echter Rebuild oder tote Spalte.
  4. Entscheidung zu bestehenden API-Tokens: pauschal widerrufen (empfohlen) oder bewusst weiterlaufen lassen.
  5. 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.

  1. app/tenancy.py löschen.
  2. app/auth/deps.py bereinigen (Liste in C.3). is_any_admin durch is_admin ersetzen. Bewusst entscheiden: require_admin_session als 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_admin bleibt für /admin/sessions* und /docs, nur die Docstrings werden entkoppelt.
  3. app/admin/routes.py: Block :124-261 ersatzlos 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.
  4. app/models/schemas.py: drei Klassen und sechs Felder entfernen. Hinweis zur API-Kompatibilität: ohne extra="forbid" ignorieren die Modelle unbekannte Felder — ein Altclient, der weiterhin tenant_id sendet, bekommt kein 422. Das ist als weiche Migration gewollt; harte Ablehnung müsste explizit konfiguriert werden.
  5. app/auth/routes.py:329-344 anpassen — Breaking Change für static/js/admin.js, deshalb zwingend gemeinsam deployen.
  6. templates/admin.html und static/js/admin.js bereinigen (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.py prüft, dass kein Inline-JS entsteht.
  7. Neue Migration 0013_drop_tenants.sql: Fremdschlüsselprüfung aus → fünf idx_*_tenant droppen → Rebuild der fünf (bzw. sechs) Tabellen → idx_tenant_admins_user droppen → tenant_admins und tenants droppen → foreign_key_check → Fremdschlüsselprüfung wieder an. Kein UNIQUE-Konflikt zu erwarten (C.2), der foreign_key_check am Ende ist trotzdem Pflicht.
  8. Nach der Migration GET /admin/audit-log/verify aufrufen: die Hash-Kette muss unverändert intakt sein.

Phase 3 — Tests und Doku

  1. tests/test_tenants.py (634 Zeilen): die sechs reinen Mandanten-Tests :93, :123, :150, :179, :246, :304 löschen; die neun allgemeinen CRUD-/Rollen-Tests :334-634 behalten und ent-mandantisieren (Helfer :48, :74-76, Payloads :160, :190, :192, :613); Datei sinnvoll umbenennen, z. B. zu test_admin_crud.py.
  2. tests/test_phase9.py:127-138 löschen (baut Mandanten-Admin per Direkt-SQL), :22 Doc-Zeile, :218-219 Payload; tests/test_phase12.py:104, 113, 365, 373, 389, 397, 410 (Spalte streichen); tests/test_phase13.py:114, :245, :317-343.
  3. Erwarteter Nebeneffekt: tests/test_admin_groups_tokens.py:78, 112, 139 und tests/test_pentest_security.py:238 senden heute Gruppen-Anlagen ohne tenant_id und laufen deshalb gegen den 400 aus _resolve_write_tenant() — das sind bereits jetzt latent rote Tests, die der Rückbau repariert.
  4. README.md: :52, Abschnitt :108-129 („Phase 8: Mandantenfaehigkeit"), :228, :250, :462-475, :490, :502-504, :629 nachziehen; den Verweis in :428 auf die SQLite-Rebuild-Begründung in 0006_tenants.sql auf die neue Migration umhängen; ergänzen, dass historische tenant_*-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.

  1. Für jeden Benutzer mit mindestens einer Zeile in user_hostgroup_roles eine Benutzergruppe anlegen. Wegen UNIQUE(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.
  2. Den Benutzer als einziges Mitglied eintragen.
  3. Alle seine Direktvergaben 1:1 nach group_hostgroup_roles übernehmen — inklusive granted_by, granted_at und expires_at, damit die Ablaufsemantik erhalten bleibt. Konflikte mit bereits bestehenden identischen Gruppenvergaben ignorieren.
  4. user_hostgroup_roles leeren bzw. umbenennen.
  5. Achse B rechteneutral vorbefüllen: für jeden Host mit zugeordnetem Zugangsdatensatz jede Gruppe freigeben, die auf der Hostgruppe dieses Hosts ssh_connect bzw. rdp_connect hat. Das reproduziert exakt das heutige Verhalten.
  6. 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/grant und /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/roles wird 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 auf GET /admin/roles/names umstellen. Hinweistext templates/admin.html:117 und Jumphost_Konzept.md:132-135 nachziehen.

D.6 Reihenfolge

  1. Vorarbeit, rechteneutral: tote Rollen entscheiden; catalog gegen h.protocol absichern (S6); Scope-Lücken S4 und S12 schließen; app/rbac.py als einzige Wahrheitsquelle etablieren und catalog darauf umstellen (S13).
  2. Teil C (Mandanten-Rückbau) muss abgeschlossen seinrdp_credentials.tenant_id ist NOT NULL und würde sonst in die neuen Freigabetabellen hineinregieren.
  3. Migration: persönliche Gruppen erzeugen, Direktvergaben spiegeln, Achse-B-Tabellen anlegen und rechteneutral vorbefüllen (D.4, Schritte 13 und 5).
  4. Lesepfade umstellen: rbac.py auf einen Zweig, catalog auf Gruppenlogik, beide Proxies auf die neue Credential-Auflösung. Ab hier ist Achse B scharf.
  5. Schreibpfade abschalten: Direktvergabe-Endpunkte auf 410, UI-Formular entfernen, user_hostgroup_roles leeren/umbenennen.
  6. Konsolidierung: Report über persönliche Gruppen, Zusammenlegung zu Teams, restriktives Aufräumen der Achse-B-Vorbefüllung.
  7. Tests: tests/test_rbac.py schreibt heute direkt in user_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:87app/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 LimitNOFILE in 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 E1E11 (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":

  1. Drei Benutzer, gleichzeitig angemeldet: zwei SSH-Sitzungen und eine RDP-Sitzung parallel.
  2. 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).
  3. Während eines 100-MiB-Uploads mit aktivem AV-Scan bleiben alle Sitzungen bedienbar.
  4. Nach dem Test: verify_chain() über das Audit-Log ist intakt, sessions enthält keine Zeile ohne ended_at, deren Task nicht mehr existiert.
  5. 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 aus templates/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 per window.close() (rdp.js:111-120, analog in terminal.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 in app/ssh_proxy/terminal_ws.py:223-246 beendet 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.

  1. Aus Skripten werden Bausteine. terminal.js und rdp.js müssen von „ein Skript pro Seite" zu „eine Instanz pro Sitzung, in einem übergebenen Container" werden — mit connect(), dispose(), setActive(bool) und einem Statusrückruf für die Seitenleiste. Feste Element-IDs (session-shell, status, terminal, rdp-display, fullscreen-btn, ft-* in templates/terminal.html:11-47) sind dabei durch instanzbezogene Referenzen zu ersetzen. Das ist der größte Einzelposten in Teil F.
  2. Tastatur und Zwischenablage dürfen nur die aktive Sitzung erreichen. new Guacamole.Keyboard(document) (rdp.js:78) und der paste-Listener am Dokument (:82) würden sonst Eingaben an alle offenen RDP-Sitzungen gleichzeitig schicken — ein Tastendruck landet auf drei Windows-Servern. Ebenso window.addEventListener("resize") in terminal.js:113. Beim Umschalten müssen gedrückte Modifiertasten zurückgesetzt werden (sonst bleibt Strg/Alt in der verlassenen Sitzung „hängen").
  3. 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 RDP client.sendSize() — das existiert bis heute überhaupt nicht (Befund D4 in Teil A). F.3.3 setzt D4 voraus.
  4. 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.
  5. 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.
  6. 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 nur idx_sessions_user, app/db/migrations/0001_initial.sql:107).
  7. 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.
  8. 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.
  9. 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.
  10. 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.
  11. 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:

  1. 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-server ist seit 2022 nicht mehr in Debian.
  2. 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.
  3. users.home_tenant_id — echter Tabellen-Rebuild oder tote Spalte, mit der Folge, dass tenants als leere Tabelle stehen bleibt (Teil C.5).
  4. Bestehende API-Tokens — pauschal widerrufen (empfohlen) oder weiterlaufen lassen (Teil C.6, Phase 0.4).
  5. Die drei toten Rollen — je Rolle ausimplementieren oder entfernen (Teil D.3).
  6. Obergrenze gleichzeitiger Sitzungen — je Benutzer und global (Teil E.4). Ohne Zahl kann Teil F die Grenze weder durchsetzen noch anzeigen.
  7. Verhalten von Hintergrundsitzungen — voll verbunden lassen oder nach einer Frist in einen Pausenzustand versetzen (Teil F.3.9).
  8. 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 tenant findet außerhalb von app/db/migrations/0006*, 0012* und den historischen Audit-Einträgen nichts mehr; PRAGMA foreign_key_check ist 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.