From 13542163d8f694f32d84dd38ce81003e70d8b50d Mon Sep 17 00:00:00 2001 From: Midas Wollinger Date: Thu, 3 Sep 2026 01:22:00 +0200 Subject: [PATCH] umbau 1.2 --- app/rdp_proxy/guacd_client.py | 19 +++- app/rdp_proxy/ws_tunnel.py | 26 +++-- static/js/terminal.js | 185 ++++++++++++++++++++++------------ 3 files changed, 148 insertions(+), 82 deletions(-) diff --git a/app/rdp_proxy/guacd_client.py b/app/rdp_proxy/guacd_client.py index 250fa0e..e236869 100644 --- a/app/rdp_proxy/guacd_client.py +++ b/app/rdp_proxy/guacd_client.py @@ -36,8 +36,25 @@ def rdp_drive_path(host_id: int, session_id: int) -> Path: A sah/ueberschrieb/las die Dateien von B. Je-Sitzung-Pfade schliessen das aus; Aufraeumen beim Sitzungsende siehe app/rdp_proxy/ws_tunnel.py (`_cleanup_rdp_drive_path`). + + Bug-Fix (Live-Test, 2./3.9.): urspruenglich als ZWEI Verzeichnis-Ebenen + unter rdp-drives/ angelegt (.../rdp-drives/{host_id}/{session_id}). + guacds RDP-Parameter "create-drive-path" legt aber nachweislich NUR das + direkte Blattverzeichnis an, keine fehlenden Elternverzeichnisse (kein + "mkdir -p") -- fuer einen Host, der noch keine Sitzung mit Dateitransfer + hatte, existierte .../rdp-drives/{host_id}/ nie, wodurch guacd mit + "Unable to create directory ... No such file or directory" abbrach. Ein + Versuch, das Elternverzeichnis stattdessen im App-Prozess selbst + anzulegen, scheiterte am echten Testserver an den Dateirechten (rdp-drives/ + gehoert dem guacd-Systembenutzer, Modus 750 -- der App-Prozess hat dort + keinerlei Zugriff). Deshalb jetzt EINE flache Ebene mit host_id UND + session_id im Verzeichnisnamen selbst: weiterhin pro Sitzung eindeutig + (Befund E6 bleibt gewahrt), aber guacd muss nur noch einen einzigen neuen + Eintrag direkt unter dem bereits existierenden rdp-drives/ anlegen -- + funktioniert nachweislich, siehe Docstring oben ("vorher war der Pfad NUR + je Host angelegt", also genau diese Verschachtelungstiefe). """ - return settings.data_dir / "rdp-drives" / str(host_id) / str(session_id) + return settings.data_dir / "rdp-drives" / f"{host_id}_{session_id}" # Der von "connect"/"select" bis zur eigentlichen Bilduebertragung genutzte # StreamReader-Puffer muss groesser als der asyncio-Default (64 KiB) sein -- diff --git a/app/rdp_proxy/ws_tunnel.py b/app/rdp_proxy/ws_tunnel.py index 1056dce..147692a 100644 --- a/app/rdp_proxy/ws_tunnel.py +++ b/app/rdp_proxy/ws_tunnel.py @@ -419,20 +419,18 @@ async def rdp_tunnel( host, password.decode(), session_id=session_id, username=cred_row[1], domain=cred_row[2], client_name=f"jumphost-{user.username}", timezone=safe_timezone, ) - if host.get("file_transfer_enabled"): - # Bug-Fix (Live-Test, RDP-Teilstrecke, 2.9.): guacd/FreeRDPs - # "create-drive-path" legt nur das BLATT-Verzeichnis an, keine - # fehlenden Elternverzeichnisse (kein "mkdir -p"). Bisher wurde - # ausschliesslich data_dir/recordings_dir beim Start angelegt - # (siehe app/db.py) -- das Elternverzeichnis .../rdp-drives// - # existierte fuer einen frischen Host nie, wodurch guacd mit - # "Unable to create directory ... No such file or directory" - # abbrach. Deshalb hier explizit VOR dem Tunnelaufbau mit - # parents=True anlegen (blockierendes Datei-I/O -> Thread, wie - # bei _cleanup_rdp_drive_path()). - await asyncio.to_thread( - rdp_drive_path(host_id, session_id).mkdir, parents=True, exist_ok=True - ) + # Bug-Fix (Live-Test, RDP-Teilstrecke, 2./3.9.): ein erster Versuch, + # das Elternverzeichnis hier im App-Prozess per mkdir(parents=True) + # anzulegen, ist am echten Testserver an den Dateirechten gescheitert + # ("/var/lib/jumphost/rdp-drives" gehoert dem guacd-Systembenutzer, + # Modus 750 -- der App-Prozess (Benutzer "jumphost") hat dort gar + # keinen Zugriff, auch nicht zum Anlegen). Der eigentliche Fix liegt + # deshalb in rdp_drive_path() (siehe guacd_client.py): eine flache, + # aber weiterhin pro Sitzung eindeutige Pfadstruktur, die guacds + # eigenes "create-drive-path" (das nachweislich NUR das direkte + # Blattverzeichnis anlegt, siehe Docstring dort) ohne fehlendes + # Elternverzeichnis anlegen kann -- ohne dass der App-Prozess selbst + # in rdp-drives/ schreiben muesste. tunnel = await open_tunnel( guacd_host=settings.guacd_host, guacd_port=settings.guacd_port, protocol="rdp", params=params, screen_width=width, screen_height=height, dpi=dpi, diff --git a/static/js/terminal.js b/static/js/terminal.js index ac8f3cc..14e5aa8 100644 --- a/static/js/terminal.js +++ b/static/js/terminal.js @@ -16,17 +16,24 @@ * Copy & Paste: xterm.js liefert Markieren-zum-Kopieren + Strg+Umschalt+V * bereits nativ. Aufgabe #11 (vom Nutzer nachtraeglich hinzugefuegt, siehe * FORTSETZUNG_Aufgabe11.md) ergaenzt zwei konkret gewuenschte Wege: - * `copyOnSelect: true` kopiert eine Markierung sofort in die - * System-Zwischenablage (kein Strg+C/Cmd+C mehr noetig -- Strg+C bleibt - * ohne Markierung weiterhin SIGINT, xterm.js' Standardverhalten). Rechtsklick - * fuegt ein: zuerst wird direkt versucht, ueber die Clipboard-API einzufuegen - * (funktioniert in den meisten Chromium-Browsern, da ein Rechtsklick eine - * "transiente Aktivierung" ist); schlaegt das fehl (Firefox/Safari ohne - * Berechtigung, unsicherer Kontext), erscheint ersatzweise ein winziges - * Kontextmenue mit einem "Einfuegen"-Eintrag -- der Klick darauf ist selbst - * eine Benutzergeste und hat dadurch nochmal bessere Chancen auf die - * Berechtigung (siehe _wireClipboard() unten). Keine serverseitige - * Sonderbehandlung noetig, im Gegensatz zu RDP (siehe rdp.js). + * `copyOnSelect: true` versucht, eine Markierung sofort in die System- + * Zwischenablage zu kopieren (kein Strg+C/Cmd+C mehr noetig -- Strg+C bleibt + * ohne Markierung weiterhin SIGINT, xterm.js' Standardverhalten). Das ist + * aber eine Automatik OHNE direkte Benutzergeste (sie laeuft aus xterm.js' + * eigenem mouseup-Handler heraus) und schlaegt daher in der Praxis manchmal + * fehl, ohne dass die App das mitbekommt (Live-Test 2.9.: Markierung+Ziehen + * hat sichtbar funktioniert, in der Zwischenablage kam aber nichts an). + * Deshalb zusaetzlich ein Rechtsklick-Kontextmenue, das PuTTYs bekannte + * Konvention nachbildet: Rechtsklick MIT aktiver Markierung kopiert (direkter + * Versuch ueber die Clipboard-API, da ein Rechtsklick selbst eine + * "transiente Aktivierung"/Benutzergeste ist -- das ist deutlich + * zuverlaessiger als copyOnSelects impliziter Aufruf); schlaegt das fehl + * oder ist keine Markierung aktiv, faellt es auf "Einfuegen" zurueck -- + * ebenfalls zuerst direkt ueber die Clipboard-API versucht, sonst ein + * winziges Kontextmenue mit "Kopieren"- bzw. "Einfuegen"-Eintrag, dessen + * Klick selbst wieder eine frische Benutzergeste ist (siehe _wireClipboard() + * unten). Keine serverseitige Sonderbehandlung noetig, im Gegensatz zu RDP + * (siehe rdp.js). * Markieren mit gedrueckter Umschalttaste: xterm.js reicht Mausklicks an ein * Vollbild-Zielprogramm mit eigener Maussteuerung (tmux, vim, htop, mc, ...) * weiter, sobald dieses per ANSI-Escape-Sequenz Maus-Tracking anfordert -- @@ -187,7 +194,7 @@ this._lastErrorMessage = null; this._transfers = []; this._resizeDebounce = null; - this._pasteMenuEl = null; + this._clipboardMenuEl = null; this.element = this._buildElement(); this._cacheRefs(); @@ -442,7 +449,7 @@ this.sendResize(); this.term.focus(); } else { - this._closePasteMenu(); + this._closeClipboardMenu(); } } @@ -475,21 +482,42 @@ }); } - // --- Zwischenablage (Aufgabe #11) -------------------------------------- + // --- Zwischenablage (Aufgabe #11, erweitert Live-Test 2.9.) ------------ // - // Kopieren laeuft ueber copyOnSelect (siehe _setupTerminal()) -- hier nur - // Einfuegen per Rechtsklick. Zwei Stufen, wie im Datei-Kopfkommentar - // beschrieben: direkter Versuch, dann ein winziges Kontextmenue als - // Ruecksicherung, wenn der Browser die Berechtigung ohne explizite - // Benutzergeste verweigert. + // copyOnSelect (siehe _setupTerminal()) bleibt als Automatik aktiv, ist + // aber nicht zuverlaessig genug (siehe Datei-Kopfkommentar) -- deshalb + // zusaetzlich Rechtsklick nach PuTTY-Konvention: MIT aktiver Markierung + // kopieren, OHNE einfuegen. Beides zweistufig wie gehabt: direkter + // Clipboard-API-Versuch, bei Fehlschlag ein winziges Kontextmenue als + // Ruecksicherung (dessen Klick selbst wieder eine frische Benutzergeste + // ist und dadurch bessere Chancen auf die Berechtigung hat). _wireClipboard() { this.terminalContainer.addEventListener("contextmenu", (ev) => { ev.preventDefault(); - this._attemptDirectPaste(ev.clientX, ev.clientY); + if (this.term.hasSelection()) { + this._attemptDirectCopy(ev.clientX, ev.clientY); + } else { + this._attemptDirectPaste(ev.clientX, ev.clientY); + } }); } + async _attemptDirectCopy(x, y) { + const text = this.term.getSelection(); + if (!navigator.clipboard || !navigator.clipboard.writeText) { + this._showCopyMenu(x, y, text); + return; + } + try { + await navigator.clipboard.writeText(text); + } catch (_) { + // Wie bei _attemptDirectPaste(): Ruecksicherung unten, deren Klick + // selbst wieder eine frische Benutzergeste ist. + this._showCopyMenu(x, y, text); + } + } + async _attemptDirectPaste(x, y) { if (!navigator.clipboard || !navigator.clipboard.readText) { this._showPasteMenu(x, y); @@ -506,19 +534,22 @@ } } - _closePasteMenu() { - if (this._pasteMenuEl) { - this._pasteMenuEl.remove(); - this._pasteMenuEl = null; + _closeClipboardMenu() { + if (this._clipboardMenuEl) { + this._clipboardMenuEl.remove(); + this._clipboardMenuEl = null; } - if (this._pasteMenuCleanup) { - this._pasteMenuCleanup(); - this._pasteMenuCleanup = null; + if (this._clipboardMenuCleanup) { + this._clipboardMenuCleanup(); + this._clipboardMenuCleanup = null; } } - _showPasteMenu(x, y) { - this._closePasteMenu(); + // Gemeinsamer Aufbau fuer Kopieren- und Einfuegen-Mini-Kontextmenue -- + // beide brauchen dieselbe Positionierung und dasselbe Schliessverhalten, + // nur Beschriftung und Klick-Aktion unterscheiden sich. + _showClipboardMenu(x, y, label, onButtonClick) { + this._closeClipboardMenu(); // CSP (style-src 'self', kein 'unsafe-inline', siehe app.css- // Kopfkommentar zu .hidden) verbietet JS-gesetzte element.style- // Aenderungen genauso wie style="..."-Attribute -- daher KEINE @@ -534,11 +565,50 @@ (isBottom ? "term-paste-menu-bottom" : "term-paste-menu-top") + " " + (isRight ? "term-paste-menu-right" : "term-paste-menu-left"); - const pasteBtn = document.createElement("button"); - pasteBtn.type = "button"; - pasteBtn.textContent = "Einfuegen"; - pasteBtn.addEventListener("click", async () => { - this._closePasteMenu(); + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = label; + btn.addEventListener("click", async () => { + this._closeClipboardMenu(); + await onButtonClick(); + this.term.focus(); + }); + menu.appendChild(btn); + + // Als Kind von .session-shell (nicht .session-terminal) angehaengt -- + // dort mischt sich nichts mit xterm.js' eigenem DOM-Teilbaum, das + // dieses Element seit term.open() exklusiv verwaltet. + this.shellEl.appendChild(menu); + this._clipboardMenuEl = menu; + + // Schliessen bei Klick daneben, Escape oder Scrollen -- Standard- + // verhalten fuer ein solches Mini-Kontextmenue (siehe auch + // workspace.js fuer dasselbe Muster bei anderen Overlays). + const onDocClick = (ev) => { + if (!menu.contains(ev.target)) this._closeClipboardMenu(); + }; + const onKeydown = (ev) => { + if (ev.key === "Escape") this._closeClipboardMenu(); + }; + const onScroll = () => this._closeClipboardMenu(); + // capture:true fuer den Klick-Listener, damit er VOR einem moeglichen + // contextmenu-Listener eines anderen Elements greift; im naechsten + // Tick registriert, sonst schliesst der Rechtsklick, der das Menue + // gerade erst geoeffnet hat, es sofort wieder (derselbe Event). + window.setTimeout(() => { + document.addEventListener("click", onDocClick, true); + document.addEventListener("keydown", onKeydown); + window.addEventListener("scroll", onScroll, true); + }, 0); + this._clipboardMenuCleanup = () => { + document.removeEventListener("click", onDocClick, true); + document.removeEventListener("keydown", onKeydown); + window.removeEventListener("scroll", onScroll, true); + }; + } + + _showPasteMenu(x, y) { + this._showClipboardMenu(x, y, "Einfuegen", async () => { try { const text = await navigator.clipboard.readText(); this.term.paste(text); @@ -549,40 +619,21 @@ true ); } - this.term.focus(); }); - menu.appendChild(pasteBtn); + } - // Als Kind von .session-shell (nicht .session-terminal) angehaengt -- - // dort mischt sich nichts mit xterm.js' eigenem DOM-Teilbaum, das - // dieses Element seit term.open() exklusiv verwaltet. - this.shellEl.appendChild(menu); - this._pasteMenuEl = menu; - - // Schliessen bei Klick daneben, Escape oder Scrollen -- Standard- - // verhalten fuer ein solches Mini-Kontextmenue (siehe auch - // workspace.js fuer dasselbe Muster bei anderen Overlays). - const onDocClick = (ev) => { - if (!menu.contains(ev.target)) this._closePasteMenu(); - }; - const onKeydown = (ev) => { - if (ev.key === "Escape") this._closePasteMenu(); - }; - const onScroll = () => this._closePasteMenu(); - // capture:true fuer den Klick-Listener, damit er VOR einem moeglichen - // contextmenu-Listener eines anderen Elements greift; im naechsten - // Tick registriert, sonst schliesst der Rechtsklick, der das Menue - // gerade erst geoeffnet hat, es sofort wieder (derselbe Event). - window.setTimeout(() => { - document.addEventListener("click", onDocClick, true); - document.addEventListener("keydown", onKeydown); - window.addEventListener("scroll", onScroll, true); - }, 0); - this._pasteMenuCleanup = () => { - document.removeEventListener("click", onDocClick, true); - document.removeEventListener("keydown", onKeydown); - window.removeEventListener("scroll", onScroll, true); - }; + _showCopyMenu(x, y, text) { + this._showClipboardMenu(x, y, "Kopieren", async () => { + try { + await navigator.clipboard.writeText(text); + } catch (err) { + console.error("Kopieren in die Zwischenablage nicht moeglich:", err); + this._setStatus( + "Kopieren nicht moeglich -- Browser verweigert Zwischenablage-Zugriff. Bitte Strg+Umschalt+C verwenden.", + true + ); + } + }); } // --- Dateitransfer-Panel --------------------------------------------- @@ -752,7 +803,7 @@ window.clearInterval(this._pingTimer); this._pingTimer = null; } - this._closePasteMenu(); + this._closeClipboardMenu(); try { if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { this.ws.close(1000, "Sitzung durch Benutzer beendet");