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

16 KiB
Raw Permalink Blame History

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 awaits 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