From 96d9f331b5dcacf61ca928deb9865bff9151c688 Mon Sep 17 00:00:00 2001 From: Midas Wollinger Date: Thu, 3 Sep 2026 08:01:27 +0200 Subject: [PATCH] umbau 1.3 --- ansible/roles/guacd/defaults/main.yml | 15 ++ ansible/roles/guacd/tasks/main.yml | 14 ++ ansible/roles/jumphost_app/tasks/main.yml | 48 +++++ .../jumphost-rdp-drives-cleanup.service.j2 | 36 ++++ .../jumphost-rdp-drives-cleanup.timer.j2 | 17 ++ app/rdp_proxy/ws_tunnel.py | 54 ++++- scripts/cleanup_rdp_drives.py | 150 +++++++++++++ static/css/app.css | 13 ++ static/js/terminal.js | 152 +++++++++---- tests/test_rdp_drive_cleanup.py | 199 ++++++++++++++++++ 10 files changed, 654 insertions(+), 44 deletions(-) create mode 100644 ansible/roles/jumphost_app/templates/jumphost-rdp-drives-cleanup.service.j2 create mode 100644 ansible/roles/jumphost_app/templates/jumphost-rdp-drives-cleanup.timer.j2 create mode 100644 scripts/cleanup_rdp_drives.py create mode 100644 tests/test_rdp_drive_cleanup.py diff --git a/ansible/roles/guacd/defaults/main.yml b/ansible/roles/guacd/defaults/main.yml index da60036..7055c89 100644 --- a/ansible/roles/guacd/defaults/main.yml +++ b/ansible/roles/guacd/defaults/main.yml @@ -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" diff --git a/ansible/roles/guacd/tasks/main.yml b/ansible/roles/guacd/tasks/main.yml index 24b71e3..3cbb0db 100644 --- a/ansible/roles/guacd/tasks/main.yml +++ b/ansible/roles/guacd/tasks/main.yml @@ -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" diff --git a/ansible/roles/jumphost_app/tasks/main.yml b/ansible/roles/jumphost_app/tasks/main.yml index 17157e6..4ce9ee7 100644 --- a/ansible/roles/jumphost_app/tasks/main.yml +++ b/ansible/roles/jumphost_app/tasks/main.yml @@ -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 diff --git a/ansible/roles/jumphost_app/templates/jumphost-rdp-drives-cleanup.service.j2 b/ansible/roles/jumphost_app/templates/jumphost-rdp-drives-cleanup.service.j2 new file mode 100644 index 0000000..66ccec4 --- /dev/null +++ b/ansible/roles/jumphost_app/templates/jumphost-rdp-drives-cleanup.service.j2 @@ -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 diff --git a/ansible/roles/jumphost_app/templates/jumphost-rdp-drives-cleanup.timer.j2 b/ansible/roles/jumphost_app/templates/jumphost-rdp-drives-cleanup.timer.j2 new file mode 100644 index 0000000..2a83aa8 --- /dev/null +++ b/ansible/roles/jumphost_app/templates/jumphost-rdp-drives-cleanup.timer.j2 @@ -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 diff --git a/app/rdp_proxy/ws_tunnel.py b/app/rdp_proxy/ws_tunnel.py index 147692a..cd5a823 100644 --- a/app/rdp_proxy/ws_tunnel.py +++ b/app/rdp_proxy/ws_tunnel.py @@ -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, diff --git a/scripts/cleanup_rdp_drives.py b/scripts/cleanup_rdp_drives.py new file mode 100644 index 0000000..2a4eb06 --- /dev/null +++ b/scripts/cleanup_rdp_drives.py @@ -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\d+)_(?P\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()) diff --git a/static/css/app.css b/static/css/app.css index afa71d0..b8eba3b 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -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