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

@ -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())