16 KiB
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:
- Die Bruecke ist zu langsam und hat kein Timeout (A1–A3). Selbst bei perfekter Konfiguration bricht die Sitzung ab oder wirkt eingefroren.
- Die Umgebung liefert guacd/RDP gar nicht aus bzw. blockiert es (B1–B6). Dann kommt nie ein Bild — meist ohne verwertbare Meldung.
- 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 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,56filtert aufr.name IN ('ssh_connect','rdp_connect')ohne das Protokoll des Hosts zu beruecksichtigen. Ein Benutzer mit nurssh_connectsieht Windows-Hosts inkl. "Verbinden"-Knopf und faellt dann in die Ablehnung 4403. - D2 — Die Aufzeichnung schreibt den kompletten Bildstrom, synchron.
recorder.py:41flusht 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 eherblob-Instruktionen ausklammern oder das guacamole-eigene Aufzeichnungsformat (recording-path) von guacd schreiben lassen. - D3 — CSP kennt kein
blob:fuer Bilder.app/main.pysetztimg-src 'self' data:. guacamole-common-js nutzt bevorzugtcreateImageBitmap()(davon nicht betroffen), faellt aber aufURL.createObjectURL()zurueck — auf einem Browser ohnecreateImageBitmapbleibt das Bild dann schwarz.blob:ergaenzen. - D4 — Kein Resize.
client.sendSize()wird nie aufgerufen; die Aufloesung steht auf dem Fenstermass beim Verbindungsaufbau fest, obwohlresize-method=display-updategesetzt 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 → readyist korrekt, alle Parameter (hostname, port, username, password, domain, security=nla, ignore-cert, disable-copy/paste, resize-method) landen an der richtigen Position [nachgewiesen]. - Subprotokoll
guacamolewird 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 mitUpgrade/Connectionund 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
systemctl status guacd; ss -ltnp | grep 4822→ laeuft guacd ueberhaupt? (B1/B2)ls /usr/lib/*/libguac-client-rdp.so* /usr/local/lib/libguac-client-rdp.so*→ RDP-Plugin gebaut? (B2)journalctl -u guacd -fwaehrend eines Verbindungsversuchs → sagt guacd etwas? (B5: gar nichts = Pakete gehen nicht raus)- Vom Jumphost aus:
nc -vz <windows-ip> 3389→ Firewall/Routing (B5) - Im Adminbereich: Zugangsdaten des Hosts pruefen — steht dort
(kein Benutzername)? (C2) - 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 |