332 lines
16 KiB
Markdown
332 lines
16 KiB
Markdown
# 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/<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 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 |
|