# Warum eine Windows-RDP-Sitzung nicht funktioniert — vollstaendige Fehlersuche Stand: 2026-08-21 · Untersucht: `app/rdp_proxy/*`, `static/js/rdp.js`, `templates/rdp.html`, `app/recordings/recorder.py`, Migrationen 0003/0009/0010/0012, `ansible/roles/{guacd,jumphost_app,nginx_proxy,firewall_nftables,sqlite_init,python_runtime}`. Alle mit **[nachgewiesen]** markierten Punkte wurden in der Sandbox gegen den ECHTEN Anwendungscode reproduziert: Fake-guacd ueber eine echte TCP-Verbindung mit der Argumentliste eines guacd 1.5, echtes starlette-WebSocket ueber ein ASGI-Nachrichtenpaar, echte SQLite-DB mit allen Migrationen. Die dafuer noetigen Sandbox-Attrappen (fastapi/aiosqlite/asyncssh/argon2) liegen unter `/tmp/work/stubs` und sind NICHT Teil des Auslieferungsstands. --- ## Kurzfassung Es gibt nicht *einen* Fehler, sondern drei Gruppen: 1. **Die Bruecke ist zu langsam und hat kein Timeout** (A1–A3). Selbst bei perfekter Konfiguration bricht die Sitzung ab oder wirkt eingefroren. 2. **Die Umgebung liefert guacd/RDP gar nicht aus bzw. blockiert es** (B1–B6). Dann kommt nie ein Bild — meist ohne verwertbare Meldung. 3. **Parameter- und Datenfehler in der connect-Instruktion** (C1–C4). Windows lehnt die Anmeldung ab oder guacd faellt auf ein Uraltprotokoll zurueck. Reihenfolge zum Abarbeiten steht am Ende unter *Pruefreihenfolge*. --- ## A. Sitzung kommt zustande, ist aber unbrauchbar / bricht ab ### A1 — Der guacd-Leser liest BYTEWEISE: ca. 1 MB/s Obergrenze, blockiert dabei den ganzen Dienst **[nachgewiesen]** `app/rdp_proxy/guacd_client.py:44-66` — `_read_until()` und `_read_chars()` holen sich jedes einzelne Byte mit `await reader.readexactly(1)`. Fuer eine RDP-Sitzung, deren Datenstrom fast nur aus base64-kodierten PNG-Kacheln besteht, sind das zehntausende `await`s pro Bildschirmaktualisierung. Messung (5,01 MB realistischer `blob`-Strom, 6-KB-Kacheln): | Variante | Zeit | Durchsatz | |---|---|---| | aktuell (`readexactly(1)` je Byte) | 4,7 s | **1,05 MB/s** | | gepuffert (`readuntil(b".")` + `readexactly(len)`) | 0,01 s | 690 MB/s | Faktor **644x**. Ein erster Bildschirmaufbau von 3 MB braucht im echten Pfad (`rdp_tunnel` end-to-end gemessen) **3,14 s** — und zwar 3,14 s reine CPU-Zeit im Event-Loop, in denen der gesamte Dienst steht: alle anderen RDP-Sitzungen, alle SSH-Sitzungen, die Admin-Oberflaeche, das Verbindungslog. Interaktive RDP-Nutzung (Fenster verschieben, scrollen, Video) erzeugt dauerhaft mehrere MB/s. Der Tunnel kommt nicht hinterher, der Rueckstand waechst unbegrenzt: Bild friert ein, Maus reagiert Sekunden spaeter, irgendwann greift der Browser-Timeout. **Das ist die wahrscheinlichste Ursache fuer "verbindet, aber es tut sich nichts".** *Behebung:* `_read_until`/`_read_chars` durch `reader.readuntil(b".")` + `readexactly(length)` ersetzen (die Zeichen-statt-Byte-Semantik aus Phase 10 bleibt erhalten: nur die noch fehlenden Zeichen einzeln nachdekodieren, siehe Prototyp in der Messung — Ergebnis war fuer ASCII wie fuer Umlaute identisch zur bisherigen Implementierung). Zusaetzlich `asyncio.open_connection(..., limit=1<<20)` setzen, damit `readuntil` bei grossen Elementen nicht in `LimitOverrunError` laeuft. ### A2 — Waehrend des guacd-/RDP-Verbindungsaufbaus schweigt der Server: Browser bricht nach 15 s ab **[nachgewiesen]** `app/rdp_proxy/ws_tunnel.py:235-244`: erst `await open_tunnel(...)`, und **danach** werden die beiden Pumpen-Tasks gestartet. Solange guacd noch mit dem Windows-Ziel verhandelt, liest niemand vom WebSocket — die Pings von guacamole-common-js (alle 5 s) landen in der Queue und werden **nicht** beantwortet. guacamole-common-js bricht nach `tunnel.receiveTimeout = 15000` ohne EINEN empfangenen Frame mit "Server timeout." ab (`static/js/vendor/guacamole-common.js:14605,15448`). Der Timer startet mit `socket.onopen`; die Tunnel-UUID zu Beginn setzt ihn genau einmal zurueck. Effektives Budget: **15 Sekunden fuer den kompletten RDP-Verbindungsaufbau.** Nachweis (echter `rdp_tunnel`, Fake-guacd mit 6 s bis `ready`, Browser sendet sekuendlich Pings): ``` t= 0.00s websocket.accept t= 0.00s websocket.send '0.,36.919c58ab-...;' <- Tunnel-UUID t= 6.01s websocket.send '4.blob,...' <- erst nach ready t= 6.01s websocket.send '0.,4.ping,...' x6 <- ALLE Pings auf einen Schlag ``` Sechs Sekunden lang ging kein einziger Frame an den Browser. Ueberschreitet der RDP-Handshake 15 s — nicht erreichbares Ziel (TCP-Timeout!), NLA/CredSSP-Aushandlung, langsame DC-Anmeldung, Zertifikatspruefung — sieht der Benutzer "Fehler: Server timeout", waehrend serverseitig alles "normal" aussieht. *Behebung:* die WS-Leseschleife VOR `open_tunnel()` starten (Pings sofort spiegeln, restliche Client-Instruktionen bis zum offenen Tunnel puffern oder verwerfen), oder waehrend des Aufbaus einen eigenen Keepalive-Task laufen lassen, der eintreffende Pings beantwortet. ### A3 — `open_tunnel()` hat kein einziges Timeout **[nachgewiesen]** `app/rdp_proxy/guacd_client.py:134-160`: weder `asyncio.open_connection` noch die `read_instruction`-Aufrufe (args, ready) sind zeitbegrenzt. Antwortet guacd nach dem TCP-Accept nicht mehr (haengendes guacd, blockierender FreeRDP-Aufruf), haengt die Coroutine **unbegrenzt**: der `sessions`-Datensatz bleibt ohne `ended_at` offen, die Aufzeichnungsdatei bleibt offen, `active_sessions` behaelt den Eintrag, das Websocket bleibt belegt. Reproduziert mit einem guacd, das nichts sendet — der Aufruf lief bis zum kuenstlichen Abbruch nach 3 s weiter. *Behebung:* `asyncio.wait_for(...)` um Verbindungsaufbau (z.B. 5 s) und um den gesamten Handshake bis `ready` (z.B. 60 s, konfigurierbar), mit einer eigenen Klartextmeldung. --- ## B. Umgebung: guacd fehlt, ist unerreichbar oder darf nicht raus ### B1 — Auf Debian 13 (trixie) wird guacd ueberhaupt nicht gebaut `ansible/roles/guacd/tasks/main.yml` dokumentiert es selbst: weder der 1.6.0-Release noch `git main` bauen gegen das FreeRDP 3.15 von trixie (GUACAMOLE-2146). Der Playbook-Lauf laesst dann entweder einen kaputten Build zurueck oder gar kein `/usr/local/sbin/guacd`. Ergebnis im Browser: `Fehler: [Errno 111] Connect call failed ('127.0.0.1', 4822)` **[nachgewiesen]**. Auf Ubuntu greift der apt-Pfad (guacd 1.3.0 aus *universe*) und funktioniert. **Erste Frage bei jeder RDP-Fehlersuche: `systemctl status guacd` und `ss -ltnp | grep 4822` auf dem Jumphost.** ### B2 — Die Rolle stellt weder "guacd laeuft" noch "RDP-Plugin vorhanden" sicher Es gibt keinen Task `service: name=guacd state=started enabled=true`; gestartet wird nur ueber den Handler, also **nur wenn sich guacd.conf geaendert hat**. Und: `./configure` erkennt FreeRDP selbst — fehlen die Header, baut es klaglos ein guacd **ohne** RDP-Plugin. guacd antwortet dann mit `error: Support for protocol "rdp" is not installed` **[nachgewiesen]**, was der Benutzer als kryptische Meldung im Browser sieht. *Behebung:* expliziter start/enable-Task; nach `make install` pruefen, dass `libguac-client-rdp.so` existiert, sonst hart abbrechen. ### B3 — Beim Quellcode-Build liest guacd die geschriebene Konfiguration gar nicht Die Rolle schreibt `/etc/guacamole/guacd.conf`. Ein mit `./configure` ohne `--sysconfdir` gebautes guacd liegt unter `/usr/local` und liest `/usr/local/etc/guacamole/guacd.conf`. `bind_host`/`bind_port` bleiben also wirkungslos. Solange alles auf den Vorgaben (localhost:4822) steht, faellt das nicht auf — **sobald jemand `guacd_port` aendert, ist RDP still tot**, weil die App auf dem neuen Port sucht und guacd weiter auf 4822 lauscht. *Behebung:* `./configure --sysconfdir=/etc` ergaenzen (oder die Datei an beide Orte schreiben). ### B4 — `lineinfile` legt `/etc/guacamole` nicht an `ansible.builtin.lineinfile` mit `create: true` legt die *Datei*, nicht das Verzeichnis an. Auf dem Debian-Quellcode-Pfad existiert `/etc/guacamole` nicht → Task schlaegt fehl → Rolle bricht ab → guacd bleibt unkonfiguriert/ungestartet. Fehlt ein `file: path=/etc/guacamole state=directory` davor. ### B5 — nftables verwirft ausgehendes RDP, wenn das Ziel nicht in `target_networks` liegt `ansible/roles/firewall_nftables/templates/jumphost.nft.j2`: die output-Chain hat `policy drop` und erlaubt ausgehend nur `ip daddr {{ target_networks }}` (Default `10.20.0.0/16`), DNS, NTP, 80/443. Steht das Windows-Ziel in einem anderen Netz (z.B. 192.168.x.x oder 10.0.x.x), werden die Pakete **still verworfen** — kein Log, kein Reject. guacd wartet dann bis zum TCP-Timeout, und wegen **A2** sieht der Benutzer "Server timeout" statt "Ziel nicht erreichbar". *Pruefen:* `nft list ruleset | grep daddr` und die Zieladresse vergleichen. ### B6 — guacd darf nicht in sein eigenes Laufwerksverzeichnis `build_rdp_params()` setzt `enable-drive=true` und `drive-path=/var/lib/jumphost/rdp-drives/`, sobald am Host `file_transfer_enabled` gesetzt ist. Die Rolle legt `rdp-drives` korrekt fuer den User `guacd` an — **aber das Elternverzeichnis `/var/lib/jumphost` ist 0700 `jumphost:jumphost`** (`roles/sqlite_init`, zusaetzlich `app/db.py:init_db()` mit `mode=0o700`). Der User `guacd` kann es nicht durchqueren (EACCES). Laufwerksumleitung schlaegt damit immer fehl; je nach guacd-Version endet das bei einer Fehlermeldung im guacd-Log — im schlechteren Fall bricht die Sitzung ab. *Behebung:* Laufwerke aus dem App-Datenverzeichnis herausnehmen (z.B. `/var/lib/guacd-drives`) oder `/var/lib/jumphost` auf 0750 mit gemeinsamer Gruppe setzen. --- ## C. Parameter- und Datenfehler in der connect-Instruktion ### C1 — Der Versions-Slot wird leer gesendet: guacd faellt auf Protokoll 1.0.0 zurueck **[nachgewiesen]** Ein echtes guacd 1.5 sendet als ERSTES Element seiner `args`-Instruktion das Versions-Token `VERSION_1_5_0`; der Client muss an derselben Position die von ihm gesprochene Version zuruecksenden. `guacd_client.py:153` (`values = [params.get(name, "") for name in arg_names]`) kennt den Namen nicht und schickt dort einen Leerstring: ``` -- Anzahl args: 85 Anzahl connect-Werte: 85 VERSION_1_5_0 = '' hostname = '10.0.0.5' ... ``` guacd stuft die Verbindung damit auf 1.0.0 zurueck. Verloren gehen u.a. `timezone`, die `required`-Instruktion (Nachfordern von Zugangsdaten) und `msg`-Meldungen. Kein sofortiger Abbruch, aber ein latenter Fehler, der bei neueren guacd-Versionen weiter aufmacht. *Behebung:* Elemente, die auf `VERSION_` passen, erkennen und die hoechste gemeinsam unterstuetzte Version zuruecksenden (Verhalten des offiziellen guacamole-client). ### C2 — Migration 0012 schreibt den Platzhalter `(kein Benutzername)` als echten Benutzernamen **[nachgewiesen]** `0012_rdp_credential_sets.sql`: `COALESCE(NULLIF(TRIM(rcl.username), ''), '(kein Benutzername)')`. Fuer jeden vor Migration 0010 angelegten Datensatz ohne Benutzernamen steht damit buchstaeblich `(kein Benutzername)` in `rdp_credentials.username`. Die Schutzpruefung in `build_rdp_params()` greift nicht (der Wert ist ja nicht leer), und dieser String wird als Anmeldename an Windows geschickt: ``` connect-Parameter username = '(kein Benutzername)' ``` Der Benutzer bekommt "Anmeldung fehlgeschlagen" statt des klaren Hinweises, dass am Zugangsdatensatz kein Benutzername hinterlegt ist. Beim leeren String funktioniert die Pruefung dagegen einwandfrei. *Behebung:* Migration nachziehen (`UPDATE rdp_credentials SET username='' WHERE username='(kein Benutzername)'`) oder den Platzhalter in `build_rdp_params()` / im Admin-UI wie "leer" behandeln. ### C3 — `width`/`height`/`dpi` stehen nicht in der connect-Instruktion Sie werden nur ueber die `size`-Handshake-Instruktion uebergeben. Das funktioniert, weil guacd bei leeren Werten auf die "optimale" Groesse des Users zurueckfaellt — es ist aber Zufall, kein Design. Ebenso fehlen `timezone` (Windows uebernimmt sonst die Serverzeitzone) und `client-name`. ### C4 — Fehlermeldungen erreichen den Browser als Python-Repr **[nachgewiesen]** Was der Benutzer aktuell sieht: ``` Fehler: Verbindungsaufbau fehlgeschlagen: ['error', 'Login failed', '769'] Fehler: Verbindungsaufbau fehlgeschlagen: ['error', 'Support for protocol "rdp" is not installed', '512'] Fehler: [Errno 111] Connect call failed ('127.0.0.1', 4822) ``` Der guacd-Statuscode (769 = Anmeldung fehlgeschlagen, 512 = Serverfehler, 519 = Ziel nicht erreichbar) wird nicht uebersetzt, und der Text wird zusaetzlich auf 123 Byte gekuerzt (RFC 6455). Fuer Bedienbarkeit und Fehlersuche: Codes auf deutsche Klartexte abbilden und den vollstaendigen Text zusaetzlich als `error`-Instruktion ueber den bereits offenen Tunnel senden, bevor geschlossen wird. --- ## D. Kleinere Befunde * **D1 — Falsche Hosts im Dashboard.** `app/catalog/routes.py:48,56` filtert auf `r.name IN ('ssh_connect','rdp_connect')` ohne das Protokoll des Hosts zu beruecksichtigen. Ein Benutzer mit nur `ssh_connect` sieht Windows-Hosts inkl. "Verbinden"-Knopf und faellt dann in die Ablehnung 4403. * **D2 — Die Aufzeichnung schreibt den kompletten Bildstrom, synchron.** `recorder.py:41` flusht bei JEDER Instruktion, aus dem Event-Loop heraus. Gemessen: 5 MB RDP-Bildstrom → 5,14 MB JSONL. Eine halbe Stunde normales Arbeiten fuellt so schnell mehrere GB unter `/var/lib/jumphost/recordings`; laeuft die Partition voll, sterben auch die SSH-Sitzungen und die Datenbank. Fuer RDP eher `blob`-Instruktionen ausklammern oder das guacamole-eigene Aufzeichnungsformat (`recording-path`) von guacd schreiben lassen. * **D3 — CSP kennt kein `blob:` fuer Bilder.** `app/main.py` setzt `img-src 'self' data:`. guacamole-common-js nutzt bevorzugt `createImageBitmap()` (davon nicht betroffen), faellt aber auf `URL.createObjectURL()` zurueck — auf einem Browser ohne `createImageBitmap` bleibt das Bild dann schwarz. `blob:` ergaenzen. * **D4 — Kein Resize.** `client.sendSize()` wird nie aufgerufen; die Aufloesung steht auf dem Fenstermass beim Verbindungsaufbau fest, obwohl `resize-method=display-update` gesetzt ist. * **D5 — Clipboard-Filter greift nur auf die erste Instruktion** einer WebSocket-Nachricht (`_ws_to_guacd`). guacamole-common-js sendet zwar eine Instruktion je Nachricht, verlassen sollte man sich darauf aber nicht. --- ## Was nachweislich in Ordnung ist Damit die Fehlersuche nicht dort weitergeht: * Handshake-Reihenfolge `select → args → size/audio/video/image → connect → ready` ist korrekt, alle Parameter (hostname, port, username, password, domain, security=nla, ignore-cert, disable-copy/paste, resize-method) landen an der richtigen Position **[nachgewiesen]**. * Subprotokoll `guacamole` wird im Handshake bestaetigt **[nachgewiesen: `accept(subprotocol='guacamole')`]** — der Fix aus Phase 10 haelt. * Tunnel-URL enthaelt keine Query-Parameter mehr, `client.connect(connectParams)` ist richtig — der Phase-10-Fix haelt. * Zeichen- statt Bytelaengen im Guacamole-Protokoll: korrekt, inkl. Umlauten **[nachgewiesen]**. * Tunnel-UUID als erste Instruktion, ping-Spiegelung, Clipboard-Sperre, RBAC-Pruefung `rdp_connect`, Entschluesselung des Passworts innerhalb des try/finally: alles wie vorgesehen **[end-to-end nachgewiesen]**. * nginx reicht `/ws/` korrekt mit `Upgrade`/`Connection` und 3600 s Read-Timeout durch; das Subprotokoll geht als normaler Header mit. * Admin-Oberflaeche und API fuer RDP-Zugangsdaten (anlegen, zuweisen, loesen) sind vollstaendig. --- ## Pruefreihenfolge fuer den konkreten Fall 1. `systemctl status guacd; ss -ltnp | grep 4822` → laeuft guacd ueberhaupt? (B1/B2) 2. `ls /usr/lib/*/libguac-client-rdp.so* /usr/local/lib/libguac-client-rdp.so*` → RDP-Plugin gebaut? (B2) 3. `journalctl -u guacd -f` waehrend eines Verbindungsversuchs → sagt guacd etwas? (B5: gar nichts = Pakete gehen nicht raus) 4. Vom Jumphost aus: `nc -vz 3389` → Firewall/Routing (B5) 5. Im Adminbereich: Zugangsdaten des Hosts pruefen — steht dort `(kein Benutzername)`? (C2) 6. Erst wenn 1–5 sauber sind: A1–A3 beheben, sonst bleibt die Sitzung langsam bzw. bricht nach 15 s ab. ## Empfohlene Reihenfolge der Behebung | # | Aufwand | Wirkung | |---|---|---| | A1 gepufferter Leser | klein (~20 Zeilen) | macht RDP ueberhaupt erst benutzbar | | A2 Leseschleife vor `open_tunnel` | mittel | beseitigt "Server timeout" beim Aufbau | | A3 Timeouts | klein | keine haengenden Sitzungen mehr | | C4 Fehlertexte | klein | jede weitere Fehlersuche wird billiger | | C2 Platzhalter-Benutzername | klein (Migration 0013) | falsche Anmeldung → klare Meldung | | B2/B3/B4/B6 Ansible | mittel | Deployment liefert wirklich ein funktionierendes guacd | | C1 Versionsaushandlung | klein | Zukunftssicherheit gegen neuere guacd | | D1–D5 | klein | Bedienbarkeit, Plattenplatz |