diff --git a/RDP_Diagnose_Phase16.md b/RDP_Diagnose_Phase16.md new file mode 100644 index 0000000..dbbb09a --- /dev/null +++ b/RDP_Diagnose_Phase16.md @@ -0,0 +1,331 @@ +# 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 | diff --git a/_to_delete/appsrc.tgz b/_to_delete/appsrc.tgz new file mode 100644 index 0000000..7c87a94 Binary files /dev/null and b/_to_delete/appsrc.tgz differ