#!/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())