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
|
||||
# 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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
"""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,
|
||||
|
||||
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;
|
||||
}
|
||||
.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
|
||||
|
||||
@ -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 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 (_) {
|
||||
// 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 (!navigator.clipboard || !navigator.clipboard.writeText) {
|
||||
this._showCopyMenu(x, y, text);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (_) {
|
||||
// Wie bei _attemptDirectPaste(): Ruecksicherung unten, deren Klick
|
||||
// selbst wieder eine frische Benutzergeste ist.
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
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