Files
ssh-jumphost/RDP_Diagnose_Phase16.md
2026-08-31 06:49:34 +02:00

332 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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** (A1A3). Selbst bei
perfekter Konfiguration bricht die Sitzung ab oder wirkt eingefroren.
2. **Die Umgebung liefert guacd/RDP gar nicht aus bzw. blockiert es** (B1B6).
Dann kommt nie ein Bild — meist ohne verwertbare Meldung.
3. **Parameter- und Datenfehler in der connect-Instruktion** (C1C4). 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/<host-id>`, 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 <windows-ip> 3389` → Firewall/Routing (B5)
5. Im Adminbereich: Zugangsdaten des Hosts pruefen — steht dort `(kein Benutzername)`? (C2)
6. Erst wenn 15 sauber sind: A1A3 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 |
| D1D5 | klein | Bedienbarkeit, Plattenplatz |