diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index 300bd98..f107f40 100644 --- a/apps/tesm/pyproject.toml +++ b/apps/tesm/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm" -version = "2.8.23" +version = "2.8.24" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm/src/tesm/blueprints/maintenance.py b/apps/tesm/src/tesm/blueprints/maintenance.py index 4113edf..b6df018 100644 --- a/apps/tesm/src/tesm/blueprints/maintenance.py +++ b/apps/tesm/src/tesm/blueprints/maintenance.py @@ -18,7 +18,7 @@ from __future__ import annotations from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for from tesm_core.security.sessions import fragment from tesm_core import audit -from tesm_core.auth.models import current_user, permission_required +from tesm_core.auth.models import current_user, permission_required, reauth_required from tesm_core.auth.service import client_ip from tesm_core.db import Database from tesm_core.extension import core, db, transaction @@ -77,7 +77,7 @@ def _query_devices(conn) -> list[dict]: # type: ignore[no-untyped-def] @bp.get("/") @permission_required(*SERVICING_PAGE, mode="any") -def overview(): +def overview(): # noqa: C901 - eine Seite, drei Aufgaben conn = db() return render_template( "tesm/maintenance.html", @@ -89,6 +89,7 @@ def overview(): terminal_enabled=bool(core().settings.get(conn, "terminal_clients_enabled")), windows_tool_missing=_windows_tool_missing(conn), windows_package=windowsops.REQUIRED_PACKAGE, + befehlsvorlagen=service.BEFEHLSVORLAGEN, ) @@ -150,6 +151,13 @@ def status(): { "running": running, "poll_ms": 2000 if running else 10000, + # Der Zustand der Geraete gehoert dazu: die Seite steht sonst mit + # dem Stand ihres Aufbaus da, und wer nach einem Neustart auf die + # Rueckkehr wartet, sieht "unbekannt", bis er von Hand neu laedt. + "devices": [ + {"id": int(row["id"]), "state": str(row["state"] or "unknown")} + for row in _eligible_devices(conn) + ], "jobs": [ { "id": job["id"], @@ -159,6 +167,16 @@ def status(): "progress": job["progress"], "message": job["message"], "updated_at": job["updated_at"], + # Die letzten Zeilen -- nur von dem, was gerade laeuft. + # Mitlesen heisst mitlesen: wer nach der Wartung wissen + # will, was war, oeffnet den Auftrag. Alles mitzuschicken + # hiesse, alle zwei Sekunden das gesamte Protokoll aller + # Geraete zu uebertragen. + "output": ( + service.letzte_zeilen(str(job["output"] or "")) + if job["state"] in {"pending", "running"} + else "" + ), } for job in jobs ], @@ -187,7 +205,7 @@ def job_output(job_id: str): return render_template("tesm/_maintenance_output.html", job=job) -def _start(kind: str) -> None: +def _start(kind: str, befehl: str = "") -> None: extension = core() conn = db() device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()] @@ -233,12 +251,27 @@ def _start(kind: str) -> None: actor_id=current_user.id, action=kind, target=str(device["name"]), - detail=f"Auftrag {handle.id}", + # Beim Befehl gehoert sein Wortlaut hierher und nicht nur die + # Auftragsnummer: was gelaufen ist, muss aus dem Protokoll + # hervorgehen, auch wenn der Auftrag laengst aus dem Verlauf + # gerollt ist. + detail=f"Auftrag {handle.id}" + (f": {befehl}" if befehl else ""), ip=client_ip(), - severity="notice", + severity="warning" if befehl else "notice", ) - if kind == service.JOB_KIND_UPDATE: + if kind == service.JOB_KIND_COMMAND: + service.start_background( + service.run_command, + database=extension.database, + job_id=handle.id, + device=dict(device), + username=username, + password=password, + befehl=befehl, + timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900), + ) + elif kind == service.JOB_KIND_UPDATE: service.start_background( service.run_update, database=extension.database, @@ -289,3 +322,22 @@ def start_update(): def start_reboot(): _start(service.JOB_KIND_REBOOT) return redirect(url_for("maintenance.overview")) + + +@bp.post("/command") +@permission_required("servicing_command.execute") +@reauth_required +def start_command(): + """Eine Befehlszeile auf allen ausgewaehlten Clients. + + Dieselbe Schwelle wie die Konsole: eigenes Recht **und** eine frische + Passwortbestaetigung. Wer hier etwas eintippt, tut es auf einem Dutzend + Maschinen gleichzeitig, und niemand sieht dabei zu. + """ + try: + befehl = service.befehl_pruefen(request.form.get("befehl", "")) + except ValueError as exc: + flash(str(exc), "danger") + return redirect(url_for("maintenance.overview")) + _start(service.JOB_KIND_COMMAND, befehl=befehl) + return redirect(url_for("maintenance.overview")) diff --git a/apps/tesm/src/tesm/blueprints/terminal.py b/apps/tesm/src/tesm/blueprints/terminal.py index b51c76d..d208dd1 100644 --- a/apps/tesm/src/tesm/blueprints/terminal.py +++ b/apps/tesm/src/tesm/blueprints/terminal.py @@ -76,6 +76,16 @@ CLIENT_COMMANDS: tuple[tuple[str, str], ...] = ( ("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"), ("apt list --upgradable 2>/dev/null", "Verfügbare Aktualisierungen"), ("uname -a", "Kernel und Architektur"), + # Die beiden schreiben -- deshalb stehen sie am Ende und sagen es im Namen. + # Eine Vorlage fuellt nur das Eingabefeld; abgeschickt wird von Hand. + ( + 'echo "Encryption=AlwaysOff" | sudo tee -a /etc/vnc/config.d/common.custom', + "VNC: Streckenverschlüsselung abschalten (schreibt)", + ), + ( + "sudo systemctl restart vncserver-x11-serviced", + "VNC-Dienst neu starten (schreibt)", + ), ) diff --git a/apps/tesm/src/tesm/permissions.py b/apps/tesm/src/tesm/permissions.py index 683d354..c2d91ff 100644 --- a/apps/tesm/src/tesm/permissions.py +++ b/apps/tesm/src/tesm/permissions.py @@ -187,6 +187,24 @@ SERVICING_TOOL = Resource( action_hints={A.EXECUTE: "Installation auslösen."}, ) +#: Ein freier Befehl auf vielen Geraeten ist das Maechtigste, was diese +#: Anwendung kann -- und das Einzige, dessen Wirkung sie nicht kennt. Deshalb +#: ein eigenes Recht, das niemand nebenbei bekommt: wer Pakete aktualisieren +#: oder neu starten darf, tut etwas Umrissenes; wer hier etwas eintippt, tut +#: alles, was ein Konto auf dem Geraet tun kann. Die Vorlagen nehmen dem den +#: Alltag ab, nicht die Verantwortung. +SERVICING_COMMAND = Resource( + key="servicing_command", + label="Befehl auf mehreren Geräten", + actions=(A.READ, A.EXECUTE), + hint="Eine Befehlszeile über SSH, auf allen ausgewählten Clients", + action_hints={ + A.READ: "Die Karte sehen – und damit, was zuletzt lief.", + A.EXECUTE: "Befehl absenden. Verlangt eine frische Passwortbestätigung; " + "jeder Auftrag steht mit seinem Wortlaut im Änderungsprotokoll.", + }, +) + #: Die Wartungsseite zeigt alle drei Aufgaben. Sie oeffnet, wer **eine** davon #: sehen darf -- und zeigt dann nur die. Als Konstante, damit die vier Routen #: dieselbe Antwort geben; einzeln getippt liefen sie irgendwann auseinander. @@ -194,6 +212,7 @@ SERVICING_PAGE: tuple[str, ...] = ( "servicing_update.read", "servicing_reboot.read", "servicing_tool.read", + "servicing_command.read", ) #: Elf Funktionen hingen an ``dhcp.edit``: Grundeinstellungen, Subnetze, @@ -354,7 +373,7 @@ MAINTENANCE_AREA = Area( icon="wrench", hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich " "verborgen – auch wenn einzelne Rechte darunter gesetzt sind.", - resources=(SERVICING_UPDATE, SERVICING_REBOOT, SERVICING_TOOL), + resources=(SERVICING_UPDATE, SERVICING_REBOOT, SERVICING_COMMAND, SERVICING_TOOL), ) NETWORK_AREA = Area( diff --git a/apps/tesm/src/tesm/services/maintenance.py b/apps/tesm/src/tesm/services/maintenance.py index 4a378a9..601fc5c 100644 --- a/apps/tesm/src/tesm/services/maintenance.py +++ b/apps/tesm/src/tesm/services/maintenance.py @@ -44,6 +44,51 @@ logger = logging.getLogger(__name__) JOB_KIND_UPDATE = "maintenance.update" JOB_KIND_REBOOT = "maintenance.reboot" +JOB_KIND_COMMAND = "maintenance.command" + +#: Vorlagen fuer den Massenbefehl: Name, Befehl, Erklaerung. +#: +#: ``{sudo}`` wird zur Laufzeit ersetzt -- mit ``sudo -n``, wo es ohne Passwort +#: geht, sonst mit ``sudo -S`` und dem hinterlegten Passwort ueber stdin. Wer +#: eine Vorlage waehlt, sieht den Befehl im Feld und kann ihn aendern: eine +#: Vorlage ist ein Vorschlag, kein Automat. +BEFEHLSVORLAGEN: tuple[tuple[str, str, str], ...] = ( + ( + "VNC: Streckenverschlüsselung abschalten", + 'echo "Encryption=AlwaysOff" | {sudo} tee -a /etc/vnc/config.d/common.custom', + "Danach bietet RealVNC auch RA2ne und „ohne Authentifizierung“ an – nur damit " + "kommt eine Sitzung im Browser zustande. Die Strecke ist dann unverschlüsselt; " + "im Netz zwischen Server und Gerät ist sie es ohnehin.", + ), + ( + "VNC-Dienst neu starten", + "{sudo} systemctl restart vncserver-x11-serviced", + "Räumt auch eine Sperre ab, die der Server nach zu vielen abgebrochenen " + "Verbindungsversuchen gesetzt hat.", + ), + ( + "VNC: aktuelle Einstellungen zeigen", + "cat /etc/vnc/config.d/common.custom 2>/dev/null || echo '(keine eigene Konfiguration)'", + "Liest nur – gut, um vor dem Ändern zu sehen, was schon dasteht.", + ), + ( + "Zeit und Zeitzone", + "timedatectl", + "Liest nur.", + ), + ( + "Belegung der Dateisysteme", + "df -h", + "Liest nur.", + ), +) + +#: Was in einer Befehlszeile nichts zu suchen hat. Nicht als Schutz vor einem +#: Menschen mit diesem Recht -- er darf ohnehin alles, was sein Konto auf dem +#: Geraet darf --, sondern gegen Unfaelle: ein Zeilenumbruch aus der +#: Zwischenablage macht aus einem Befehl unbemerkt zwei. +VERBOTENE_ZEICHEN = ("\n", "\r", "\x00") +MAX_BEFEHLSLAENGE = 500 MAX_OUTPUT_LINES = 400 CONNECT_TIMEOUT = 25 @@ -231,7 +276,10 @@ def update_job( def list_jobs( - conn: sqlite3.Connection, *, kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT), limit: int = 60 + conn: sqlite3.Connection, + *, + kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT, JOB_KIND_COMMAND), + limit: int = 60, ) -> list[dict[str, Any]]: placeholders = ",".join("?" * len(kinds)) return Database.all( @@ -447,6 +495,128 @@ def run_update( ) +#: So viele Zeilen gehen ins Mitlesen. Genug, um zu sehen, was gerade +#: passiert; wenig genug, dass eine Abfrage alle zwei Sekunden nichts kostet. +MITLESEN_ZEILEN = 40 + + +def letzte_zeilen(ausgabe: str, anzahl: int = MITLESEN_ZEILEN) -> str: + """Das Ende einer Ausgabe -- das ist der Teil, der gerade entsteht.""" + zeilen = [zeile for zeile in ausgabe.splitlines() if zeile.strip()] + return "\n".join(zeilen[-anzahl:]) + + +def befehl_pruefen(befehl: str) -> str: + """Gibt den bereinigten Befehl zurueck -- oder wirft mit Klartext. + + Eine eigene Funktion, weil genau diese Frage sich pruefen lassen muss, + ohne ein Geraet zu brauchen. + """ + sauber = (befehl or "").strip() + if not sauber: + raise ValueError("Es wurde kein Befehl eingegeben.") + if len(sauber) > MAX_BEFEHLSLAENGE: + raise ValueError( + f"Der Befehl ist zu lang (mehr als {MAX_BEFEHLSLAENGE} Zeichen). " + "Für längere Abläufe ist ein Skript auf dem Gerät der bessere Weg." + ) + for zeichen in VERBOTENE_ZEICHEN: + if zeichen in sauber: + raise ValueError( + "Der Befehl enthält einen Zeilenumbruch. Hier läuft genau eine " + "Befehlszeile – alles danach würde stillschweigend mitlaufen." + ) + return sauber + + +def run_command( + database: Any, + *, + job_id: str, + device: dict[str, Any], + username: str, + password: str, + befehl: str, + timeout: int, +) -> None: + """Fuehrt **eine** Befehlszeile auf einem Geraet aus. Eigener Thread. + + Bewusst ohne Deutung des Ergebnisses: was der Befehl tut, weiss der + Mensch, der ihn geschrieben hat. Festgehalten wird, was zurueckkam und mit + welchem Rueckgabewert -- mehr kann ein Werkzeug hier nicht beitragen, und + weniger waere zu wenig. + """ + name = str(device.get("name") or device.get("ip")) + + def emit(line: str) -> None: + stamp = datetime.now().strftime("%H:%M:%S") + update_job(database, job_id, append_output=f"{stamp} {line}\n") + + update_job( + database, job_id, state="running", progress=5, message="Verbindung wird aufgebaut" + ) + try: + with database.session() as conn: + transport = open_transport( + conn, + host=str(device["ip"]), + port=int(device.get("ssh_port") or 22), + username=username, + password=password, + ) + except SshError as exc: + emit(f"FEHLER: {exc}") + update_job(database, job_id, state="failed", message=str(exc), finished=True) + return + + status = 1 + try: + sudo, sudo_stdin = _sudo_mode(transport, password, emit) + # Nur wer {sudo} benutzt, bekommt es auch: ein Befehl ohne diese Marke + # laeuft als der angemeldete Benutzer, und das ist die richtige + # Vorgabe -- Rechte holt man sich hier ausdruecklich. + ausgefuehrt = befehl.replace("{sudo}", sudo) if "{sudo}" in befehl else befehl + emit(f"$ {ausgefuehrt}") + update_job(database, job_id, progress=40, message="Befehl läuft") + status = _run_remote_command( + transport, + ausgefuehrt, + timeout=timeout, + on_line=emit, + stdin_text=sudo_stdin if "{sudo}" in befehl else "", + ) + except Exception as exc: # noqa: BLE001 - der Auftrag soll das Ergebnis tragen + emit(f"FEHLER: {exc}") + finally: + try: + transport.close() + except Exception: # noqa: BLE001 + pass + + emit(COMPLETION_MARKER) + update_job( + database, + job_id, + state="succeeded" if status == 0 else "failed", + progress=100, + message=( + "Befehl ausgeführt." + if status == 0 + else f"Der Befehl endete mit Rückgabewert {status}." + ), + finished=True, + ) + with database.session() as conn, database.transaction(conn): + audit.log( + conn, + actor="system", + action="maintenance.command_finished", + target=name, + detail=f"Rückgabewert {status}", + severity="notice" if status == 0 else "warning", + ) + + def _reboot_windows( database: Any, *, diff --git a/apps/tesm/src/tesm/services/monitor.py b/apps/tesm/src/tesm/services/monitor.py index 493900c..72eb404 100644 --- a/apps/tesm/src/tesm/services/monitor.py +++ b/apps/tesm/src/tesm/services/monitor.py @@ -594,7 +594,16 @@ def run_sweep( # bekannten Stand stehen. Ein Geraet, das gerade neu startet, soll # seinen Knopf nicht verlieren. return device, ergebnis, None - return device, ergebnis, vnc_dienst.zugang_kurz(device, port=vnc_port) + try: + return device, ergebnis, vnc_dienst.zugang_kurz(device, port=vnc_port) + except Exception: # noqa: BLE001 + # Die Ueberwachung ist die Kernaufgabe, der Bildschirmzugriff eine + # Zugabe. Was hier schiefgeht -- ein Name, den der Resolver nicht + # verdaut, ein Netz, das seltsam antwortet --, darf niemals den + # ganzen Durchlauf kosten: sonst stuenden alle Geraete auf + # "unbekannt", und niemand brächte das mit VNC in Verbindung. + logger.warning("VNC-Pruefung fuer %s uebersprungen", device.get("name"), exc_info=True) + return device, ergebnis, None with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool: results = list(pool.map(pruefen, devices)) diff --git a/apps/tesm/src/tesm/services/vnc.py b/apps/tesm/src/tesm/services/vnc.py index 7d1b5f5..aee8c91 100644 --- a/apps/tesm/src/tesm/services/vnc.py +++ b/apps/tesm/src/tesm/services/vnc.py @@ -132,7 +132,9 @@ def aufloesbar(name: str) -> bool: try: socket.getaddrinfo(name, None) ergebnis = True - except OSError: + except Exception: # noqa: BLE001 + # Nicht nur OSError: ein Name mit Sonderzeichen wirft UnicodeError, + # und der ist kein OSError. Aufloesbar ist er so oder so nicht. ergebnis = False _AUFLOESUNG[name] = (jetzt, ergebnis) return ergebnis diff --git a/apps/tesm/src/tesm/templates/tesm/_vnc_flaeche.html b/apps/tesm/src/tesm/templates/tesm/_vnc_flaeche.html index a59b143..9fbbe8b 100644 --- a/apps/tesm/src/tesm/templates/tesm/_vnc_flaeche.html +++ b/apps/tesm/src/tesm/templates/tesm/_vnc_flaeche.html @@ -36,5 +36,7 @@ title="Strg+Alt+Entf an das Gerät senden">Strg+Alt+Entf + {% endmacro %} diff --git a/apps/tesm/src/tesm/templates/tesm/maintenance.html b/apps/tesm/src/tesm/templates/tesm/maintenance.html index 0cee86c..fbcd71d 100644 --- a/apps/tesm/src/tesm/templates/tesm/maintenance.html +++ b/apps/tesm/src/tesm/templates/tesm/maintenance.html @@ -47,6 +47,48 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por