943 lines
77 KiB
Markdown
943 lines
77 KiB
Markdown
# 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.
|
||
|
||
6. `app/tenancy.py` löschen.
|
||
7. `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.
|
||
8. `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.
|
||
9. `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.
|
||
10. `app/auth/routes.py:329-344` anpassen — **Breaking Change für `static/js/admin.js`**,
|
||
deshalb zwingend gemeinsam deployen.
|
||
11. `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.
|
||
12. 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.
|
||
13. Nach der Migration `GET /admin/audit-log/verify` aufrufen: die Hash-Kette muss unverändert
|
||
intakt sein.
|
||
|
||
**Phase 3 — Tests und Doku**
|
||
|
||
14. `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`.
|
||
15. `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`.
|
||
16. **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**.
|
||
17. `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 sein** — `rdp_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 1–3 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: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 `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 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":
|
||
|
||
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.
|