umbau 1.3

This commit is contained in:
2026-09-03 08:01:27 +02:00
parent 13542163d8
commit 96d9f331b5
10 changed files with 654 additions and 44 deletions

View File

@ -24,3 +24,18 @@ guacd_freerdp_dev_package: ""
# wird. Default "main" (Standard-Branch von apache/guacamole-server auf
# GitHub -- NICHT "master").
guacd_git_ref: "main"
# Protokollierstufe von guacd (guacd.conf: trace|debug|info|warning|error).
#
# "info" ist der Standard und reicht fuer den Normalbetrieb -- auf dieser
# Stufe protokolliert guacd bereits die fuer die RDP-Fehlersuche wichtigste
# Zeile: "Certificate validation bypassed" (Zertifikatspruefung wurde wegen
# ignore-cert uebersprungen) bzw. "Certificate validation failed".
#
# Auf "debug" hochsetzen, um bei einem fehlgeschlagenen RDP-Verbindungsaufbau
# den KONKRETEN FreeRDP-/OpenSSL-Fehler zu sehen, statt nur guacds
# zusammengefasste Meldung ("SSL/TLS connection failed ...", die im Browser
# als "Ziel nicht erreichbar (Code 519)" ankommt). Danach wieder auf "info"
# zuruecksetzen: "debug"/"trace" sind gespraechig und koennen Details der
# Zielumgebung ins Journal schreiben.
guacd_log_level: "info"

View File

@ -271,6 +271,20 @@
create: true
notify: restart guacd
- name: guacd-Protokollierstufe setzen
# Auf "info" (Standard) protokolliert guacd bereits, ob die
# Zertifikatspruefung wegen ignore-cert uebersprungen wurde
# ("Certificate validation bypassed") oder fehlgeschlagen ist -- die
# entscheidende Unterscheidung bei "Ziel nicht erreichbar (Code 519):
# SSL/TLS connection failed". Fuer den konkreten FreeRDP-/OpenSSL-Fehler
# guacd_log_level voruebergehend auf "debug" setzen (siehe defaults).
ansible.builtin.lineinfile:
path: /etc/guacamole/guacd.conf
regexp: '^log_level'
line: "log_level = {{ guacd_log_level }}"
create: true
notify: restart guacd
- name: RDP-Laufwerksumleitungs-Verzeichnisse anlegen (Filetransfer, Konzept 4.3)
ansible.builtin.file:
path: "/var/lib/jumphost/rdp-drives"

View File

@ -74,3 +74,51 @@
daemon_reload: true
enabled: true
state: started
# --- Aufraeumen der RDP-Laufwerksverzeichnisse (Befund E6, Nachtrag 3.9.) ----
#
# Die App kann ihre eigenen Sitzungsverzeichnisse unter
# {{ jumphost_data_dir }}/rdp-drives NICHT entfernen: das Verzeichnis gehoert
# guacd (0750 guacd:guacd, Rolle "guacd"), die darin von guacd selbst
# angelegten Sitzungsverzeichnisse haben Modus 0700 guacd:guacd, und der
# App-Benutzer "{{ jumphost_app_user }}" ist nicht in der Gruppe "guacd".
# Der bisherige shutil.rmtree()-Aufruf in app/rdp_proxy/ws_tunnel.py lief
# deshalb seit jeher ins Leere -- und hat den PermissionError wegen
# ignore_errors=True auch noch stillschweigend verschluckt. Aufgeraeumt wird
# daher hier, per Timer und als root.
- name: Aufraeum-Skript fuer RDP-Laufwerke ausrollen
ansible.builtin.copy:
src: "{{ playbook_dir }}/../scripts/cleanup_rdp_drives.py"
dest: "{{ jumphost_home }}/scripts/cleanup_rdp_drives.py"
owner: root
group: "{{ jumphost_app_group }}"
mode: "0755"
- name: systemd-Unit fuer das Aufraeumen der RDP-Laufwerke ausrollen
ansible.builtin.template:
src: jumphost-rdp-drives-cleanup.service.j2
dest: /etc/systemd/system/jumphost-rdp-drives-cleanup.service
owner: root
group: root
mode: "0644"
notify: reload systemd
- name: systemd-Timer fuer das Aufraeumen der RDP-Laufwerke ausrollen
ansible.builtin.template:
src: jumphost-rdp-drives-cleanup.timer.j2
dest: /etc/systemd/system/jumphost-rdp-drives-cleanup.timer
owner: root
group: root
mode: "0644"
notify: reload systemd
- name: Anstehende systemd-Handler anwenden, bevor der Timer gestartet wird
ansible.builtin.meta: flush_handlers
- name: Aufraeum-Timer aktivieren und starten
ansible.builtin.systemd:
name: jumphost-rdp-drives-cleanup.timer
daemon_reload: true
enabled: true
state: started

View File

@ -0,0 +1,36 @@
[Unit]
Description=Jumphost: beendete RDP-Laufwerksverzeichnisse aufraeumen
Documentation=file://{{ jumphost_home }}/scripts/cleanup_rdp_drives.py
After=jumphost-app.service
[Service]
Type=oneshot
# BEWUSST als root: /var/lib/jumphost/rdp-drives gehoert guacd (0750) und die
# darin von guacd angelegten Sitzungsverzeichnisse haben Modus 0700 guacd:guacd.
# Weder der App-Benutzer "{{ jumphost_app_user }}" noch guacd selbst koennen
# sie zuverlaessig wieder entfernen (guacd kennt das Sitzungsende nicht) --
# ausfuehrliche Begruendung im Kopfkommentar des Skripts.
User=root
Group=root
Environment=JUMPHOST_DATA_DIR={{ jumphost_data_dir }}
ExecStart={{ jumphost_venv }}/bin/python {{ jumphost_home }}/scripts/cleanup_rdp_drives.py
# Hardening: der Dienst braucht ausschliesslich Lesezugriff auf die
# Sitzungsdatenbank und Schreibzugriff auf rdp-drives -- sonst nichts.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictNamespaces=true
# Kein Netzwerk noetig (reiner Datei-/SQLite-Zugriff).
PrivateNetwork=true
RestrictAddressFamilies=AF_UNIX
RestrictSUIDSGID=true
LockPersonality=true
ReadWritePaths={{ jumphost_data_dir }}/rdp-drives
ReadOnlyPaths={{ jumphost_data_dir }}
UMask=0077

View File

@ -0,0 +1,17 @@
[Unit]
Description=Jumphost: RDP-Laufwerksverzeichnisse regelmaessig aufraeumen
[Timer]
# Alle 15 Minuten. Das Skript loescht ausschliesslich Verzeichnisse, deren
# Sitzung in der Datenbank als BEENDET markiert ist -- ein kurzes Intervall
# ist deshalb ungefaehrlich (keine Altersheuristik, die eine lange, aber
# stille Sitzung mitten im Betrieb treffen koennte) und haelt die Zeitspanne
# klein, in der uebertragene Dateien nach Sitzungsende noch auf der Platte
# liegen.
OnBootSec=5min
OnUnitActiveSec=15min
AccuracySec=1min
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -63,15 +63,57 @@ _TIMEZONE_RE = re.compile(r"^[A-Za-z0-9_+\-]+(/[A-Za-z0-9_+\-]+)*$")
async def _cleanup_rdp_drive_path(host_id: int, session_id: int) -> None:
"""Entfernt das pro Sitzung umgeleitete RDP-Laufwerk beim Sitzungsende
(Befund E6, Umsetzungsauftrag Teil E) -- sonst sammeln sich pro Sitzung
Verzeichnisse unbegrenzt an. shutil.rmtree ist blockierendes Datei-I/O
(siehe E.0) und laeuft deshalb in einem Thread statt direkt im
Event-Loop."""
"""Bester Versuch, das pro Sitzung umgeleitete RDP-Laufwerk beim
Sitzungsende zu entfernen (Befund E6, Umsetzungsauftrag Teil E).
WICHTIG (Korrektur nach Live-Test 3.9.): dieser Aufruf ist NICHT der
eigentliche Aufraeummechanismus und kann es auch nicht sein. Der
App-Prozess laeuft als "jumphost" und hat auf
{data_dir}/rdp-drives (0750 guacd:guacd) sowie auf die darin von guacd
selbst angelegten Sitzungsverzeichnisse (0700 guacd:guacd, guacd ruft
mkdir mit S_IRWXU auf, die guacd-Unit setzt zusaetzlich UMask=0077)
keinerlei Zugriff -- rmtree scheitert hier zwangslaeufig mit EACCES.
Die vorherige Fassung rief `shutil.rmtree(path, True)` auf. Das zweite
Positionsargument von rmtree ist `ignore_errors`; mit ignore_errors=True
wird der PermissionError INNERHALB von rmtree abgefangen, der
`except OSError`-Zweig darunter also nie erreicht. Ergebnis: kein
Logeintrag, keine Fehlermeldung -- und die Sitzungsverzeichnisse samt
der darin uebertragenen Dateien blieben seit Inbetriebnahme unbegrenzt
liegen, obwohl Befund E6 als erledigt galt.
Tatsaechlich aufgeraeumt wird jetzt durch scripts/cleanup_rdp_drives.py,
das ein systemd-Timer als root ausfuehrt (siehe
ansible/roles/jumphost_app) und das nur Verzeichnisse entfernt, deren
Sitzung in der Datenbank als beendet markiert ist. Der Versuch hier
bleibt trotzdem stehen: laeuft die App in einer anderen Aufstellung
(Entwicklungsumgebung, Testsuite, gemeinsamer Benutzer fuer App und
guacd), raeumt er sofort auf statt erst beim naechsten Timerlauf.
shutil.rmtree ist blockierendes Datei-I/O (siehe E.0) und laeuft deshalb
in einem Thread statt direkt im Event-Loop.
"""
path = rdp_drive_path(host_id, session_id)
try:
await asyncio.to_thread(shutil.rmtree, path, True)
# ignore_errors NICHT setzen -- Fehler sollen hier sichtbar sein,
# statt wie zuvor stillschweigend in rmtree zu verschwinden.
await asyncio.to_thread(shutil.rmtree, path)
except FileNotFoundError:
# Regelfall bei abgeschalteter Laufwerksumleitung: guacd hat nie ein
# Verzeichnis angelegt. Kein Fehler.
pass
except PermissionError:
# Der in der Produktivaufstellung ERWARTETE Fall (siehe Docstring).
# Bewusst DEBUG statt WARNING: sonst stuende bei jeder einzelnen
# Sitzung eine Warnung im Log, die keinerlei Handlung erfordert --
# zustaendig ist der Timer.
logger.debug(
"RDP-Laufwerksverzeichnis %s bleibt liegen (erwartet: keine Rechte "
"als App-Benutzer); Aufraeumen uebernimmt jumphost-rdp-drives-cleanup.timer",
path,
)
except OSError:
# Alles Uebrige ist unerwartet und gehoert sichtbar ins Log.
logger.warning(
"Konnte RDP-Laufwerksverzeichnis fuer Sitzung %s (Host %s) nicht entfernen: %s",
session_id, host_id, path, exc_info=True,

View File

@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Entfernt die pro RDP-Sitzung umgeleiteten Laufwerksverzeichnisse unter
{data_dir}/rdp-drives, sobald die zugehoerige Sitzung beendet ist.
Warum ein eigener, als root laufender Dienst und nicht die App selbst
(Befund E6, Umsetzungsauftrag Teil E -- Nachtrag Live-Test 3.9.):
Die App hat auf diese Verzeichnisse GRUNDSAETZLICH keinen Zugriff, und das
ist Absicht:
/var/lib/jumphost 0710 jumphost:jumphost
/var/lib/jumphost/rdp-drives 0750 guacd:guacd (Rolle guacd)
.../rdp-drives/{host}_{session} 0700 guacd:guacd (von guacd selbst
per "create-drive-path" angelegt; guacd
ruft mkdir mit S_IRWXU auf, die Unit setzt
zusaetzlich UMask=0077)
Der App-Prozess laeuft als "jumphost" und ist NICHT in der Gruppe "guacd"
(umgekehrt schon: guacd ist in der Gruppe "jumphost", damit es
/var/lib/jumphost ueberhaupt durchqueren kann). Fuer "other" bleiben auf
rdp-drives 0 Bits -- kein Lesen, kein Schreiben, kein Betreten. Ein
shutil.rmtree() aus der App heraus kann daher NIE erfolgreich sein.
Genau das ist die alte Fassung von app/rdp_proxy/ws_tunnel.py::
_cleanup_rdp_drive_path() gewesen -- und weil sie rmtree mit
ignore_errors=True aufrief, ist der PermissionError stillschweigend
verschluckt worden: kein Logeintrag, keine Fehlermeldung, und die
Verzeichnisse (inklusive der darin uebertragenen Dateien) sind seit
Inbetriebnahme unbegrenzt liegengeblieben.
Das laesst sich nicht ueber Gruppenrechte reparieren: die Blattverzeichnisse
haben Modus 0700, eine Gruppenmitgliedschaft wuerde daran nichts aendern, und
guacd das Recht zu geben, sie selbst wieder abzuraeumen, kann guacd nicht --
es kennt das Sitzungsende nicht.
Deshalb dieser Weg: ein systemd-Timer ruft dieses Skript als root auf. Es
loescht ein Verzeichnis NUR dann, wenn dessen Sitzungs-ID in der Datenbank
als beendet markiert ist (sessions.ended_at IS NOT NULL) -- also kein
Altersheuristik-Zeitfenster, das eine lange, aber stille Sitzung mitten im
Betrieb abraeumen koennte.
Aufruf (siehe ansible/roles/jumphost_app):
cleanup_rdp_drives.py [--data-dir /var/lib/jumphost] [--dry-run]
"""
from __future__ import annotations
import argparse
import logging
import os
import re
import shutil
import sqlite3
import sys
from pathlib import Path
# Verzeichnisname, den app/rdp_proxy/guacd_client.py::rdp_drive_path() erzeugt:
# "{host_id}_{session_id}". Alles, was diesem Muster NICHT entspricht, wird
# bewusst in Ruhe gelassen -- dieses Skript laeuft als root und soll unter
# keinen Umstaenden etwas anfassen, das es nicht selbst zuordnen kann.
_DRIVE_DIR_RE = re.compile(r"^(?P<host_id>\d+)_(?P<session_id>\d+)$")
logger = logging.getLogger("jumphost.cleanup_rdp_drives")
def _open_sessions(db_path: Path) -> set[int]:
"""IDs aller Sitzungen, die NOCH LAUFEN (ended_at IS NULL).
Bewusst die laufenden statt der beendeten abfragen: ein Verzeichnis wird
nur geloescht, wenn seine Sitzung nachweislich NICHT mehr in dieser Menge
steht. Faellt die Abfrage aus, bricht das Skript ab (siehe main) --
lieber nichts loeschen als das Falsche.
"""
# Nur-Lesend oeffnen: dieses Skript darf die Datenbank unter keinen
# Umstaenden veraendern, und ein versehentliches Anlegen einer leeren DB
# (Standardverhalten von sqlite3.connect bei falschem Pfad) wuerde jede
# Sitzung als "beendet" erscheinen lassen und ALLES loeschen.
uri = f"file:{db_path}?mode=ro"
with sqlite3.connect(uri, uri=True, timeout=10.0) as conn:
rows = conn.execute("SELECT id FROM sessions WHERE ended_at IS NULL").fetchall()
return {int(r[0]) for r in rows}
def cleanup(data_dir: Path, dry_run: bool = False) -> tuple[int, int]:
"""Gibt (entfernt, uebersprungen) zurueck."""
drives_dir = data_dir / "rdp-drives"
if not drives_dir.is_dir():
logger.info("Kein Verzeichnis %s vorhanden -- nichts zu tun.", drives_dir)
return (0, 0)
open_ids = _open_sessions(data_dir / "jumphost.db")
logger.debug("Laufende Sitzungen laut Datenbank: %s", sorted(open_ids) or "keine")
removed = skipped = 0
for entry in sorted(drives_dir.iterdir()):
match = _DRIVE_DIR_RE.match(entry.name)
if not entry.is_dir() or not match:
logger.debug("Uebersprungen (kein Sitzungsverzeichnis): %s", entry)
skipped += 1
continue
session_id = int(match.group("session_id"))
if session_id in open_ids:
logger.debug("Uebersprungen (Sitzung %s laeuft noch): %s", session_id, entry)
skipped += 1
continue
if dry_run:
logger.info("[dry-run] Wuerde entfernen: %s", entry)
removed += 1
continue
try:
shutil.rmtree(entry)
except OSError:
# Nicht ignorieren: wenn selbst root hier scheitert, stimmt etwas
# Grundsaetzliches nicht (Einhaengepunkt, Dateisystem read-only)
# und das gehoert sichtbar ins Journal -- genau der Fehler, den
# die alte App-seitige Fassung verschluckt hat.
logger.warning("Konnte %s nicht entfernen", entry, exc_info=True)
skipped += 1
continue
logger.info("Entfernt: %s (Sitzung %s beendet)", entry, session_id)
removed += 1
return (removed, skipped)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
"--data-dir",
default=os.environ.get("JUMPHOST_DATA_DIR", "/var/lib/jumphost"),
help="Datenverzeichnis der Anwendung (Standard: $JUMPHOST_DATA_DIR oder /var/lib/jumphost)",
)
parser.add_argument("--dry-run", action="store_true", help="Nur anzeigen, was entfernt wuerde")
parser.add_argument("--verbose", "-v", action="store_true", help="Debug-Ausgabe")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(levelname)s %(message)s",
)
try:
removed, skipped = cleanup(Path(args.data_dir), dry_run=args.dry_run)
except sqlite3.Error:
# Ohne verlaessliche Sitzungsliste wird NICHTS geloescht.
logger.error("Datenbank nicht lesbar -- es wurde nichts entfernt", exc_info=True)
return 1
logger.info("Fertig: %d entfernt, %d uebersprungen.", removed, skipped)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -220,6 +220,19 @@ pre.log-view {
color: var(--text); font-weight: 400; font-size: 0.85rem; border-radius: 4px;
}
.term-paste-menu button:hover { background: var(--border); filter: none; }
/* Ruecksprung fuer das Kopieren, wenn der Browser die Clipboard-API
verweigert (terminal.js::_copyViaExecCommand). document.execCommand("copy")
kopiert die aktuelle Auswahl -- dafuer muss das <textarea> TATSAECHLICH
gerendert sein: mit display:none oder .hidden laesst es sich nicht
selektieren und es wuerde leerer Text kopiert. Deshalb sichtbar im
Layout, aber vollstaendig transparent und ausserhalb der Wahrnehmung.
position:fixed statt absolute, damit .select() die Seite nicht scrollt. */
.clipboard-helper {
position: fixed; top: 0; left: 0; width: 1px; height: 1px;
padding: 0; margin: 0; border: none; outline: none; box-shadow: none;
background: transparent; color: transparent; opacity: 0; pointer-events: none;
}
.session-toolbar .status.warning { color: #f5b942; }
/* F1 (Umsetzungsauftrag_Sonnet5.md Teil F.3.1): vormals #terminal/#rdp-display
(feste, dokumentweit eindeutige IDs -- eine Seite hatte immer genau eine

View File

@ -13,27 +13,37 @@
*
* Vollbild: native Browser-Fullscreen-API auf dem Instanz-Wurzelelement
* (Konzept 4.3).
* 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` 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).
* Copy & Paste (Aufgabe #11, KORRIGIERT nach Live-Test 3.9.):
*
* FEHLERURSACHE der bisherigen Fassung: sie setzte `copyOnSelect: true` in
* den Terminal-Optionen. DIESE OPTION EXISTIERT IN XTERM.JS NICHT -- sie ist
* eine VS-Code-Einstellung ("terminal.integrated.copyOnSelection"). xterm.js'
* OptionsService uebernimmt aus dem Konstruktor-Objekt ausschliesslich
* Schluessel, die in seinen Default-Optionen vorkommen, und verwirft alle
* uebrigen KOMMENTARLOS -- kein Fehler, keine Warnung, keine Wirkung.
* Gegenprobe: `grep -c copyOnSelect static/js/vendor/xterm.js` liefert 0.
* "Markieren kopiert" hat damit nie funktioniert; das im Live-Test am 2.9.
* beobachtete "Markierung sichtbar, Zwischenablage bleibt leer" war genau
* das und NICHT das dort vermutete Benutzergesten-Problem.
*
* Jetzt drei Wege, keiner davon auf eine Berechtigungsabfrage angewiesen:
* 1. Markieren kopiert -- selbst implementiert ueber term.onSelectionChange().
* 2. Strg+Einfg kopiert, Umschalt+Einfg fuegt ein (PuTTY-Konvention, per
* attachCustomKeyEventHandler). BEWUSST NICHT Strg+Umschalt+C/V: die
* sind in Chrome/Edge fest fuer die Entwicklertools belegt und erreichen
* die Seite gar nicht -- die alten Fehlertexte ("Bitte Strg+Umschalt+C
* verwenden") liefen dort also ins Leere. Strg+V und Umschalt+Einfg
* erzeugen ein natives 'paste'-Ereignis auf xterm.js' verstecktem
* <textarea>, das xterm.js selbst verarbeitet -- ganz ohne Clipboard-API.
* 3. Rechtsklick: mit Markierung kopieren, ohne Markierung einfuegen
* (Ruecksicherung ueber ein Mini-Kontextmenue, dessen Klick eine frische
* Benutzergeste ist).
* Das Kopieren faellt bei blockierter Clipboard-API auf
* document.execCommand("copy") zurueck (siehe _copyViaExecCommand): veraltet,
* aber ohne Berechtigung nutzbar und auch dann funktionsfaehig, wenn der
* Browser die Clipboard-API verweigert -- etwa auf einer Seite mit
* selbstsigniertem Zertifikat. 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 --
@ -298,11 +308,9 @@
fontFamily: "Menlo, Consolas, monospace",
fontSize: 14,
theme: TERMINAL_THEMES[this._themeName].theme,
// Aufgabe #11: eine Markierung wird sofort in die System-
// Zwischenablage kopiert -- kein zusaetzliches Strg+C/Cmd+C noetig.
// Strg+C OHNE Markierung bleibt unveraendert SIGINT (xterm.js'
// Standardverhalten, wird von copyOnSelect nicht beruehrt).
copyOnSelect: true,
// KEIN copyOnSelect hier -- die Option gibt es in xterm.js nicht
// (siehe Kopfkommentar). Kopieren-beim-Markieren macht jetzt
// _wireClipboard() selbst ueber term.onSelectionChange().
});
this.fitAddon = new FitAddon.FitAddon();
this.term.loadAddon(this.fitAddon);
@ -493,6 +501,37 @@
// ist und dadurch bessere Chancen auf die Berechtigung hat).
_wireClipboard() {
// (1) Markieren kopiert -- SELBST implementiert, weil xterm.js keine
// copyOnSelect-Option kennt (siehe Kopfkommentar). onSelectionChange
// feuert aus xterm.js' mouseup-/Tastatur-Handler heraus, also noch
// innerhalb der transienten Benutzeraktivierung des Browsers --
// writeText() ist dort erlaubt. Schlaegt es dennoch fehl, greift der
// execCommand-Ruecksprung in _copyToClipboard().
this.term.onSelectionChange(() => {
const text = this.term.getSelection();
if (!text) return;
this._copyToClipboard(text);
});
// (2) Tastatur nach PuTTY-Konvention. BEWUSST Einfg-Kombinationen und
// nicht Strg+Umschalt+C/V -- letztere sind in Chrome/Edge fest fuer
// die Entwicklertools reserviert und erreichen die Seite nie.
this.term.attachCustomKeyEventHandler((ev) => {
if (ev.type !== "keydown") return true;
// Strg+Einfg: Markierung kopieren, nicht an die Sitzung schicken.
if (ev.ctrlKey && !ev.shiftKey && ev.key === "Insert") {
const text = this.term.getSelection();
if (text) this._copyToClipboard(text);
return false;
}
// Umschalt+Einfg und Strg+V: NICHT abfangen. Der Browser erzeugt
// daraus selbst ein natives 'paste'-Ereignis auf xterm.js'
// verstecktem <textarea>, das xterm.js bereits verarbeitet -- ganz
// ohne Clipboard-API und damit ohne Berechtigungsabfrage.
return true;
});
// (3) Rechtsklick: mit Markierung kopieren, ohne Markierung einfuegen.
this.terminalContainer.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
if (this.term.hasSelection()) {
@ -503,17 +542,54 @@
});
}
async _attemptDirectCopy(x, y) {
const text = this.term.getSelection();
if (!navigator.clipboard || !navigator.clipboard.writeText) {
this._showCopyMenu(x, y, text);
return;
}
/** Kopiert Text in die Zwischenablage. Zuerst ueber die Clipboard-API,
* bei Fehlschlag ueber document.execCommand("copy"). Letzteres gilt als
* veraltet, ist aber weiterhin in allen aktuellen Browsern implementiert,
* braucht KEINE Berechtigung und funktioniert auch dann, wenn der Browser
* die Clipboard-API verweigert (haeufigster Fall in diesem Projekt: eine
* Seite mit selbstsigniertem Zertifikat). Liefert true bei Erfolg. */
async _copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (_) {
// Wie bei _attemptDirectPaste(): Ruecksicherung unten, deren Klick
// selbst wieder eine frische Benutzergeste ist.
// Weiter mit dem Ruecksprung unten.
}
}
return this._copyViaExecCommand(text);
}
_copyViaExecCommand(text) {
const ta = document.createElement("textarea");
ta.value = text;
// Positionierung ueber eine CSS-Klasse, NICHT ueber element.style --
// die CSP dieses Projekts verbietet Inline-Styles (style-src 'self',
// kein 'unsafe-inline'), siehe app/main.py. Und bewusst nicht
// display:none/hidden: ein nicht gerendertes Element laesst sich nicht
// selektieren, execCommand("copy") kopierte dann einen leeren Text.
ta.className = "clipboard-helper";
document.body.appendChild(ta);
let ok = false;
try {
ta.focus();
ta.select();
ta.setSelectionRange(0, ta.value.length);
ok = document.execCommand("copy");
} catch (err) {
console.error("Kopieren ueber execCommand fehlgeschlagen:", err);
ok = false;
}
ta.remove();
this.term.focus();
return ok;
}
async _attemptDirectCopy(x, y) {
const text = this.term.getSelection();
if (!(await this._copyToClipboard(text))) {
// Beide Wege verweigert -- Ruecksicherung, deren Klick selbst wieder
// eine frische Benutzergeste ist.
this._showCopyMenu(x, y, text);
}
}
@ -615,7 +691,7 @@
} catch (err) {
console.error("Einfuegen aus der Zwischenablage nicht moeglich:", err);
this._setStatus(
"Einfuegen nicht moeglich -- Browser verweigert Zwischenablage-Zugriff. Bitte Strg+Umschalt+V verwenden.",
"Einfuegen nicht moeglich -- der Browser verweigert den Lesezugriff auf die Zwischenablage. Bitte stattdessen Strg+V oder Umschalt+Einfg druecken (das geht ohne Berechtigung, das Terminal muss nur den Fokus haben).",
true
);
}
@ -625,11 +701,11 @@
_showCopyMenu(x, y, text) {
this._showClipboardMenu(x, y, "Kopieren", async () => {
try {
await navigator.clipboard.writeText(text);
if (!(await this._copyToClipboard(text))) throw new Error("execCommand(copy) lieferte false");
} 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.",
"Kopieren nicht moeglich -- der Browser verweigert den Schreibzugriff auf die Zwischenablage. Bitte den markierten Text mit Strg+Einfg kopieren.",
true
);
}

View File

@ -0,0 +1,199 @@
"""Tests zu den beiden am 3.9. gefundenen, still fehlschlagenden Fehlern:
1. scripts/cleanup_rdp_drives.py -- der Aufraeummechanismus fuer die pro
Sitzung umgeleiteten RDP-Laufwerke (Befund E6). Die vorherige,
App-interne Loesung konnte prinzipiell nicht funktionieren (Rechte) und
hat ihren PermissionError wegen shutil.rmtree(..., ignore_errors=True)
auch noch verschluckt.
2. static/js/terminal.js -- Regressionsschutz gegen die Fehlerklasse
"Terminal-Option, die es in xterm.js gar nicht gibt". Genau daran ist
Copy-on-Select gescheitert (copyOnSelect stammt aus VS Code, nicht aus
xterm.js; xterm.js verwirft unbekannte Optionen kommentarlos).
"""
from __future__ import annotations
import importlib.util
import json
import re
import sqlite3
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parent.parent
def _load_cleanup_module():
path = PROJECT_ROOT / "scripts" / "cleanup_rdp_drives.py"
spec = importlib.util.spec_from_file_location("cleanup_rdp_drives", path)
module = importlib.util.module_from_spec(spec)
sys.modules["cleanup_rdp_drives"] = module
spec.loader.exec_module(module)
return module
@pytest.fixture()
def data_dir(tmp_path: Path) -> Path:
"""Datenverzeichnis mit DB und ein paar Laufwerksverzeichnissen:
Sitzung 66 und 70 beendet, Sitzung 67 laeuft noch."""
(tmp_path / "rdp-drives").mkdir()
for name in ("4_66", "4_67", "5_70"):
(tmp_path / "rdp-drives" / name).mkdir()
(tmp_path / "rdp-drives" / "4_66" / "uebertragen.txt").write_text("inhalt")
conn = sqlite3.connect(tmp_path / "jumphost.db")
conn.execute("CREATE TABLE sessions (id INTEGER PRIMARY KEY, ended_at TEXT)")
conn.executemany(
"INSERT INTO sessions (id, ended_at) VALUES (?, ?)",
[(66, "2026-09-03T10:00:00.000Z"), (67, None), (70, "2026-09-03T09:00:00.000Z")],
)
conn.commit()
conn.close()
return tmp_path
def test_entfernt_nur_beendete_sitzungen(data_dir: Path):
"""Kern der Sache: eine NOCH LAUFENDE Sitzung darf ihr Laufwerk nicht
unter den Fuessen weggeraeumt bekommen. Deshalb entscheidet der
Datenbankzustand und keine Altersheuristik ueber mtime -- eine lange,
aber stille Sitzung (Fernwartung ueber Nacht) waere sonst betroffen."""
module = _load_cleanup_module()
removed, _ = module.cleanup(data_dir)
drives = data_dir / "rdp-drives"
assert removed == 2
assert not (drives / "4_66").exists()
assert not (drives / "5_70").exists()
assert (drives / "4_67").is_dir(), "laufende Sitzung wurde faelschlich entfernt"
def test_fremde_eintraege_bleiben_unberuehrt(data_dir: Path):
"""Das Skript laeuft als root. Alles, was nicht exakt dem von
rdp_drive_path() erzeugten Muster '{host_id}_{session_id}' entspricht,
wird bewusst nicht angefasst."""
module = _load_cleanup_module()
drives = data_dir / "rdp-drives"
(drives / "fremdes_verzeichnis").mkdir()
(drives / "lose_datei").write_text("x")
(drives / "4_66_backup").mkdir()
module.cleanup(data_dir)
assert (drives / "fremdes_verzeichnis").is_dir()
assert (drives / "lose_datei").is_file()
assert (drives / "4_66_backup").is_dir()
def test_ohne_lesbare_datenbank_wird_nichts_geloescht(tmp_path: Path):
"""Sicherung gegen den schlimmsten denkbaren Fall: ohne verlaessliche
Sitzungsliste wuerde JEDE Sitzung als beendet gelten. Die DB wird
deshalb nur-lesend geoeffnet (mode=ro) -- sqlite3.connect wuerde sonst
stillschweigend eine leere Datenbank anlegen."""
module = _load_cleanup_module()
(tmp_path / "rdp-drives" / "4_66").mkdir(parents=True)
with pytest.raises(sqlite3.Error):
module.cleanup(tmp_path)
assert (tmp_path / "rdp-drives" / "4_66").is_dir()
assert not (tmp_path / "jumphost.db").exists(), "DB wurde faelschlich angelegt"
def test_dry_run_loescht_nichts(data_dir: Path):
module = _load_cleanup_module()
removed, _ = module.cleanup(data_dir, dry_run=True)
assert removed == 2
assert (data_dir / "rdp-drives" / "4_66").is_dir()
def test_verzeichnisname_passt_zu_rdp_drive_path():
"""Bindet das Skript an die Anwendung: aendert jemand rdp_drive_path()
(etwa zurueck auf die verschachtelte Form host/session, die am 2.9. den
EACCES ausgeloest hat), schlaegt dieser Test fehl statt dass der
Aufraeumdienst stumm nichts mehr findet."""
from app.rdp_proxy.guacd_client import rdp_drive_path
module = _load_cleanup_module()
name = rdp_drive_path(4, 66).name
match = module._DRIVE_DIR_RE.match(name)
assert match is not None, f"rdp_drive_path erzeugt '{name}', das Skript erwartet '{{host}}_{{session}}'"
assert match.group("host_id") == "4"
assert match.group("session_id") == "66"
assert rdp_drive_path(4, 66).parent.name == "rdp-drives", "eine Ebene tiefer -> guacd kann sie nicht anlegen"
# --- Frontend-Regressionsschutz ---------------------------------------------
def _vendor_xterm() -> str:
return (PROJECT_ROOT / "static" / "js" / "vendor" / "xterm.js").read_text(
encoding="utf-8", errors="ignore"
)
def _terminal_js(ohne_kommentare: bool = False) -> str:
"""terminal.js, auf Wunsch ohne Kommentare.
Noetig, weil die Kommentare den frueheren Fehler ABSICHTLICH beim Namen
nennen ("KEIN copyOnSelect hier ...", "Bitte Strg+Umschalt+C verwenden"
als Zitat des alten, falschen Hinweistextes). Eine reine Textsuche ueber
die ganze Datei wuerde genau diese Dokumentation als Rueckfall werten.
"""
source = (PROJECT_ROOT / "static" / "js" / "terminal.js").read_text(encoding="utf-8")
if not ohne_kommentare:
return source
source = re.sub(r"/\*.*?\*/", "", source, flags=re.DOTALL) # Blockkommentare
source = re.sub(r"^\s*//.*$", "", source, flags=re.MULTILINE) # ganze Kommentarzeilen
return source
def test_terminal_js_setzt_keine_unbekannten_xterm_optionen():
"""xterm.js verwirft unbekannte Konstruktor-Optionen KOMMENTARLOS. Genau
das ist mit `copyOnSelect: true` passiert (eine VS-Code-Einstellung, in
xterm.js nicht existent): kein Fehler, keine Warnung, keine Wirkung --
'Markieren kopiert' war seit jeher tot. Dieser Test prueft jede im
Terminal-Konstruktor gesetzte Option gegen die tatsaechlich mitgelieferte
xterm.js-Datei."""
source = _terminal_js(ohne_kommentare=True)
vendor = _vendor_xterm()
block = re.search(r"new Terminal\(\{(.*?)\n \}\);", source, re.DOTALL)
assert block, "Terminal-Konstruktoraufruf in terminal.js nicht gefunden"
options = set(re.findall(r"^\s{8}([A-Za-z][A-Za-z0-9]*):", block.group(1), re.MULTILINE))
assert options, "keine Optionen erkannt -- Test muss angepasst werden"
unbekannt = sorted(o for o in options if f"{o}" not in vendor)
assert not unbekannt, (
f"terminal.js setzt Optionen, die die ausgelieferte xterm.js nicht kennt: {unbekannt}. "
"xterm.js ignoriert sie stillschweigend -- die Funktion fehlt dann ohne jede Fehlermeldung."
)
def test_copyonselect_ist_und_bleibt_verschwunden():
"""Punktgenauer Schutz gegen exakt diesen Rueckfall."""
source = _terminal_js(ohne_kommentare=True)
konstruktor = re.search(r"new Terminal\(\{(.*?)\n \}\);", source, re.DOTALL)
assert konstruktor and "copyOnSelect" not in konstruktor.group(1)
assert "copyOnSelect" not in _vendor_xterm(), (
"xterm.js kennt copyOnSelect inzwischen doch -- dann darf dieser Test angepasst werden."
)
def test_kopieren_beim_markieren_ist_selbst_implementiert():
"""Der Ersatz fuer die nicht existente Option: onSelectionChange +
eigener Clipboard-Aufruf mit execCommand-Ruecksprung."""
source = _terminal_js(ohne_kommentare=True)
assert "this.term.onSelectionChange(" in source
assert "_copyToClipboard" in source
assert 'document.execCommand("copy")' in source
def test_keine_hinweise_auf_in_chrome_belegte_tastenkombinationen():
"""Strg+Umschalt+C/V sind in Chrome/Edge fest fuer die Entwicklertools
reserviert und erreichen die Seite nie -- ein Fehlertext, der genau das
empfiehlt, schickt den Benutzer in die Irre."""
source = _terminal_js(ohne_kommentare=True)
fehlerhafte_hinweise = re.findall(r'"[^"]*Bitte[^"]*Strg\+Umschalt\+[CV][^"]*"', source)
assert not fehlerhafte_hinweise, fehlerhafte_hinweise