umbau 1.3
This commit is contained in:
@ -24,3 +24,18 @@ guacd_freerdp_dev_package: ""
|
|||||||
# wird. Default "main" (Standard-Branch von apache/guacamole-server auf
|
# wird. Default "main" (Standard-Branch von apache/guacamole-server auf
|
||||||
# GitHub -- NICHT "master").
|
# GitHub -- NICHT "master").
|
||||||
guacd_git_ref: "main"
|
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"
|
||||||
|
|||||||
@ -271,6 +271,20 @@
|
|||||||
create: true
|
create: true
|
||||||
notify: restart guacd
|
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)
|
- name: RDP-Laufwerksumleitungs-Verzeichnisse anlegen (Filetransfer, Konzept 4.3)
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "/var/lib/jumphost/rdp-drives"
|
path: "/var/lib/jumphost/rdp-drives"
|
||||||
|
|||||||
@ -74,3 +74,51 @@
|
|||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
enabled: true
|
enabled: true
|
||||||
state: started
|
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
|
||||||
|
|||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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:
|
async def _cleanup_rdp_drive_path(host_id: int, session_id: int) -> None:
|
||||||
"""Entfernt das pro Sitzung umgeleitete RDP-Laufwerk beim Sitzungsende
|
"""Bester Versuch, das pro Sitzung umgeleitete RDP-Laufwerk beim
|
||||||
(Befund E6, Umsetzungsauftrag Teil E) -- sonst sammeln sich pro Sitzung
|
Sitzungsende zu entfernen (Befund E6, Umsetzungsauftrag Teil E).
|
||||||
Verzeichnisse unbegrenzt an. shutil.rmtree ist blockierendes Datei-I/O
|
|
||||||
(siehe E.0) und laeuft deshalb in einem Thread statt direkt im
|
WICHTIG (Korrektur nach Live-Test 3.9.): dieser Aufruf ist NICHT der
|
||||||
Event-Loop."""
|
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)
|
path = rdp_drive_path(host_id, session_id)
|
||||||
try:
|
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:
|
except OSError:
|
||||||
|
# Alles Uebrige ist unerwartet und gehoert sichtbar ins Log.
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Konnte RDP-Laufwerksverzeichnis fuer Sitzung %s (Host %s) nicht entfernen: %s",
|
"Konnte RDP-Laufwerksverzeichnis fuer Sitzung %s (Host %s) nicht entfernen: %s",
|
||||||
session_id, host_id, path, exc_info=True,
|
session_id, host_id, path, exc_info=True,
|
||||||
|
|||||||
150
scripts/cleanup_rdp_drives.py
Normal file
150
scripts/cleanup_rdp_drives.py
Normal 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())
|
||||||
@ -220,6 +220,19 @@ pre.log-view {
|
|||||||
color: var(--text); font-weight: 400; font-size: 0.85rem; border-radius: 4px;
|
color: var(--text); font-weight: 400; font-size: 0.85rem; border-radius: 4px;
|
||||||
}
|
}
|
||||||
.term-paste-menu button:hover { background: var(--border); filter: none; }
|
.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; }
|
.session-toolbar .status.warning { color: #f5b942; }
|
||||||
/* F1 (Umsetzungsauftrag_Sonnet5.md Teil F.3.1): vormals #terminal/#rdp-display
|
/* F1 (Umsetzungsauftrag_Sonnet5.md Teil F.3.1): vormals #terminal/#rdp-display
|
||||||
(feste, dokumentweit eindeutige IDs -- eine Seite hatte immer genau eine
|
(feste, dokumentweit eindeutige IDs -- eine Seite hatte immer genau eine
|
||||||
|
|||||||
@ -13,27 +13,37 @@
|
|||||||
*
|
*
|
||||||
* Vollbild: native Browser-Fullscreen-API auf dem Instanz-Wurzelelement
|
* Vollbild: native Browser-Fullscreen-API auf dem Instanz-Wurzelelement
|
||||||
* (Konzept 4.3).
|
* (Konzept 4.3).
|
||||||
* Copy & Paste: xterm.js liefert Markieren-zum-Kopieren + Strg+Umschalt+V
|
* Copy & Paste (Aufgabe #11, KORRIGIERT nach Live-Test 3.9.):
|
||||||
* bereits nativ. Aufgabe #11 (vom Nutzer nachtraeglich hinzugefuegt, siehe
|
*
|
||||||
* FORTSETZUNG_Aufgabe11.md) ergaenzt zwei konkret gewuenschte Wege:
|
* FEHLERURSACHE der bisherigen Fassung: sie setzte `copyOnSelect: true` in
|
||||||
* `copyOnSelect: true` versucht, eine Markierung sofort in die System-
|
* den Terminal-Optionen. DIESE OPTION EXISTIERT IN XTERM.JS NICHT -- sie ist
|
||||||
* Zwischenablage zu kopieren (kein Strg+C/Cmd+C mehr noetig -- Strg+C bleibt
|
* eine VS-Code-Einstellung ("terminal.integrated.copyOnSelection"). xterm.js'
|
||||||
* ohne Markierung weiterhin SIGINT, xterm.js' Standardverhalten). Das ist
|
* OptionsService uebernimmt aus dem Konstruktor-Objekt ausschliesslich
|
||||||
* aber eine Automatik OHNE direkte Benutzergeste (sie laeuft aus xterm.js'
|
* Schluessel, die in seinen Default-Optionen vorkommen, und verwirft alle
|
||||||
* eigenem mouseup-Handler heraus) und schlaegt daher in der Praxis manchmal
|
* uebrigen KOMMENTARLOS -- kein Fehler, keine Warnung, keine Wirkung.
|
||||||
* fehl, ohne dass die App das mitbekommt (Live-Test 2.9.: Markierung+Ziehen
|
* Gegenprobe: `grep -c copyOnSelect static/js/vendor/xterm.js` liefert 0.
|
||||||
* hat sichtbar funktioniert, in der Zwischenablage kam aber nichts an).
|
* "Markieren kopiert" hat damit nie funktioniert; das im Live-Test am 2.9.
|
||||||
* Deshalb zusaetzlich ein Rechtsklick-Kontextmenue, das PuTTYs bekannte
|
* beobachtete "Markierung sichtbar, Zwischenablage bleibt leer" war genau
|
||||||
* Konvention nachbildet: Rechtsklick MIT aktiver Markierung kopiert (direkter
|
* das und NICHT das dort vermutete Benutzergesten-Problem.
|
||||||
* Versuch ueber die Clipboard-API, da ein Rechtsklick selbst eine
|
*
|
||||||
* "transiente Aktivierung"/Benutzergeste ist -- das ist deutlich
|
* Jetzt drei Wege, keiner davon auf eine Berechtigungsabfrage angewiesen:
|
||||||
* zuverlaessiger als copyOnSelects impliziter Aufruf); schlaegt das fehl
|
* 1. Markieren kopiert -- selbst implementiert ueber term.onSelectionChange().
|
||||||
* oder ist keine Markierung aktiv, faellt es auf "Einfuegen" zurueck --
|
* 2. Strg+Einfg kopiert, Umschalt+Einfg fuegt ein (PuTTY-Konvention, per
|
||||||
* ebenfalls zuerst direkt ueber die Clipboard-API versucht, sonst ein
|
* attachCustomKeyEventHandler). BEWUSST NICHT Strg+Umschalt+C/V: die
|
||||||
* winziges Kontextmenue mit "Kopieren"- bzw. "Einfuegen"-Eintrag, dessen
|
* sind in Chrome/Edge fest fuer die Entwicklertools belegt und erreichen
|
||||||
* Klick selbst wieder eine frische Benutzergeste ist (siehe _wireClipboard()
|
* die Seite gar nicht -- die alten Fehlertexte ("Bitte Strg+Umschalt+C
|
||||||
* unten). Keine serverseitige Sonderbehandlung noetig, im Gegensatz zu RDP
|
* verwenden") liefen dort also ins Leere. Strg+V und Umschalt+Einfg
|
||||||
* (siehe rdp.js).
|
* 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
|
* Markieren mit gedrueckter Umschalttaste: xterm.js reicht Mausklicks an ein
|
||||||
* Vollbild-Zielprogramm mit eigener Maussteuerung (tmux, vim, htop, mc, ...)
|
* Vollbild-Zielprogramm mit eigener Maussteuerung (tmux, vim, htop, mc, ...)
|
||||||
* weiter, sobald dieses per ANSI-Escape-Sequenz Maus-Tracking anfordert --
|
* weiter, sobald dieses per ANSI-Escape-Sequenz Maus-Tracking anfordert --
|
||||||
@ -298,11 +308,9 @@
|
|||||||
fontFamily: "Menlo, Consolas, monospace",
|
fontFamily: "Menlo, Consolas, monospace",
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
theme: TERMINAL_THEMES[this._themeName].theme,
|
theme: TERMINAL_THEMES[this._themeName].theme,
|
||||||
// Aufgabe #11: eine Markierung wird sofort in die System-
|
// KEIN copyOnSelect hier -- die Option gibt es in xterm.js nicht
|
||||||
// Zwischenablage kopiert -- kein zusaetzliches Strg+C/Cmd+C noetig.
|
// (siehe Kopfkommentar). Kopieren-beim-Markieren macht jetzt
|
||||||
// Strg+C OHNE Markierung bleibt unveraendert SIGINT (xterm.js'
|
// _wireClipboard() selbst ueber term.onSelectionChange().
|
||||||
// Standardverhalten, wird von copyOnSelect nicht beruehrt).
|
|
||||||
copyOnSelect: true,
|
|
||||||
});
|
});
|
||||||
this.fitAddon = new FitAddon.FitAddon();
|
this.fitAddon = new FitAddon.FitAddon();
|
||||||
this.term.loadAddon(this.fitAddon);
|
this.term.loadAddon(this.fitAddon);
|
||||||
@ -493,6 +501,37 @@
|
|||||||
// ist und dadurch bessere Chancen auf die Berechtigung hat).
|
// ist und dadurch bessere Chancen auf die Berechtigung hat).
|
||||||
|
|
||||||
_wireClipboard() {
|
_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) => {
|
this.terminalContainer.addEventListener("contextmenu", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
if (this.term.hasSelection()) {
|
if (this.term.hasSelection()) {
|
||||||
@ -503,17 +542,54 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async _attemptDirectCopy(x, y) {
|
/** Kopiert Text in die Zwischenablage. Zuerst ueber die Clipboard-API,
|
||||||
const text = this.term.getSelection();
|
* bei Fehlschlag ueber document.execCommand("copy"). Letzteres gilt als
|
||||||
if (!navigator.clipboard || !navigator.clipboard.writeText) {
|
* veraltet, ist aber weiterhin in allen aktuellen Browsern implementiert,
|
||||||
this._showCopyMenu(x, y, text);
|
* braucht KEINE Berechtigung und funktioniert auch dann, wenn der Browser
|
||||||
return;
|
* 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 {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Wie bei _attemptDirectPaste(): Ruecksicherung unten, deren Klick
|
// Weiter mit dem Ruecksprung unten.
|
||||||
// selbst wieder eine frische Benutzergeste ist.
|
}
|
||||||
|
}
|
||||||
|
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);
|
this._showCopyMenu(x, y, text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -615,7 +691,7 @@
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Einfuegen aus der Zwischenablage nicht moeglich:", err);
|
console.error("Einfuegen aus der Zwischenablage nicht moeglich:", err);
|
||||||
this._setStatus(
|
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
|
true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -625,11 +701,11 @@
|
|||||||
_showCopyMenu(x, y, text) {
|
_showCopyMenu(x, y, text) {
|
||||||
this._showClipboardMenu(x, y, "Kopieren", async () => {
|
this._showClipboardMenu(x, y, "Kopieren", async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
if (!(await this._copyToClipboard(text))) throw new Error("execCommand(copy) lieferte false");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Kopieren in die Zwischenablage nicht moeglich:", err);
|
console.error("Kopieren in die Zwischenablage nicht moeglich:", err);
|
||||||
this._setStatus(
|
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
|
true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
199
tests/test_rdp_drive_cleanup.py
Normal file
199
tests/test_rdp_drive_cleanup.py
Normal 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
|
||||||
Reference in New Issue
Block a user