diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index d1ccc6f..e7acff3 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.16" +version = "2.8.17" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm/src/tesm/blueprints/devices.py b/apps/tesm/src/tesm/blueprints/devices.py index 9c5c410..cf02dfb 100644 --- a/apps/tesm/src/tesm/blueprints/devices.py +++ b/apps/tesm/src/tesm/blueprints/devices.py @@ -374,13 +374,13 @@ def detail(device_id: int): def _vnc_zugang(conn: Any, device: dict[str, Any]) -> vnc.Zugang | None: - """Der Bildschirmzugriff dieses Clients -- frisch gefragt, nicht geraten. + """Der Bildschirmzugriff dieses Clients -- aus dem, was bekannt ist. - Die Kacheln der Statusuebersicht nehmen den Stand aus dem letzten - Ueberwachungsdurchlauf; hier geht es um **ein** Geraet, also fragt die Seite - selbst nach (ein halbe Sekunde Zeitlimit, und nur wenn der Client gerade - erreichbar ist). Das Ergebnis wird gleich festgehalten -- dann stimmt auch - die Kachel, ohne auf den naechsten Durchlauf zu warten. + Diese Seite fragt das Geraet **nicht**. Erreichbarkeit kommt aus dem + Ueberwachungsdurchlauf, die Anmeldeverfahren aus der letzten + ausdruecklichen Pruefung. Der Grund steht in :func:`vnc.zugang_gemerkt`: + jede Messung ist eine angefangene Protokollverhandlung, und ein RealVNC + Server zaehlt so etwas mit. """ extension = core() if not current_user.has("device_vnc.execute"): @@ -388,15 +388,7 @@ def _vnc_zugang(conn: Any, device: dict[str, Any]) -> vnc.Zugang | None: if not extension.settings.get(conn, "vnc_enabled"): return None port = int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT) - online = str(device.get("state") or "") == "online" and not device.get("deleted_at") - zugang, frisch = vnc.zugang_gemerkt(device, port=port, pruefen=online) - if frisch: - with transaction(): - monitor.merke_vnc(conn, int(device["id"]), zugang.erreichbar, zugang.ziel) - monitor.merke_verfahren( - conn, int(device["id"]), zugang.verfahren, zugang.absage - ) - return zugang + return vnc.zugang_gemerkt(device, port=port) @bp.get("//vnc") diff --git a/apps/tesm/src/tesm/blueprints/screen.py b/apps/tesm/src/tesm/blueprints/screen.py index c72f752..9cc2446 100644 --- a/apps/tesm/src/tesm/blueprints/screen.py +++ b/apps/tesm/src/tesm/blueprints/screen.py @@ -30,7 +30,7 @@ import threading import time from typing import Any -from flask import Blueprint, abort, render_template +from flask import Blueprint, abort, flash, redirect, render_template, url_for from tesm_core import audit from tesm_core.auth.models import current_user, permission_required, reauth_required from tesm_core.auth.service import client_ip, reauth_is_fresh @@ -71,11 +71,13 @@ def _port(conn: Any) -> int: def screen(device_id: int): """Die Seite mit der Leinwand. - Sie misst vor dem Verbinden, **wie** der VNC-Server sich authentifizieren - will. Bietet er nur Verfahren an, die der Browser-Client nicht kennt, steht - das als Satz auf der Seite -- mit der Zeile, die das auf dem Geraet aendert. - Der Unterschied zur schwarzen Flaeche ist der ganze Punkt: wer nicht weiss, - woran es liegt, sucht den Fehler bei TESM. + Sie beruehrt das Geraet nicht: die Sitzung selbst ist der einzige Anlass, + dort anzuklopfen. Was ueber die Anmeldeverfahren bekannt ist, steht in der + Datenbank -- aus der letzten ausdruecklichen Pruefung (Knopf "Verfahren + pruefen"). Dass diese Seite frueher selbst gemessen hat, war ein Fehler: + jede Messung ist eine angefangene und abgebrochene Protokollverhandlung, + und ein RealVNC Server zaehlt so etwas -- das Oeffnen der Seite hat der + Sitzung also selbst im Weg gestanden. """ conn = db() device = _geraet(conn, device_id) @@ -85,17 +87,7 @@ def screen(device_id: int): if not port: abort(404) - zugang, frisch = vnc.zugang_gemerkt( - device, port=port, pruefen=str(device.get("state") or "") == "online" - ) - if frisch: - from ..services import monitor - - with core().database.transaction(conn): - monitor.merke_vnc(conn, int(device["id"]), zugang.erreichbar, zugang.ziel) - monitor.merke_verfahren( - conn, int(device["id"]), zugang.verfahren, zugang.absage - ) + zugang = vnc.zugang_gemerkt(device, port=port) benutzer, passwort = "", "" darf_geheimnis = bool(current_user.has("credentials.secrets")) if device.get("credential_id") and darf_geheimnis: @@ -132,6 +124,43 @@ def screen(device_id: int): ) +@bp.post("/monitoring/clients//screen/probe") +@permission_required("device_vnc.execute") +@reauth_required +def probe(device_id: int): + """Fragt das Geraet ausdruecklich, wie es sich authentifizieren will. + + Ein Knopf und keine Nebenwirkung des Seitenaufbaus: die Messung bricht eine + Protokollverhandlung ab, und RealVNC zaehlt das. Sie gehoert in die Hand + dessen, der gerade sucht -- nicht in jeden Aufruf. + """ + conn = db() + device = _geraet(conn, device_id) + if device is None: + abort(404) + port = _port(conn) + if not port: + abort(404) + + from ..services import monitor + + zugang = vnc.zugang(device, port=port) + with core().database.transaction(conn): + monitor.merke_vnc(conn, int(device["id"]), zugang.erreichbar, zugang.ziel) + monitor.merke_verfahren(conn, int(device["id"]), zugang.verfahren, zugang.absage) + if zugang.absage: + flash(f"Der Server antwortet: „{zugang.absage}“", "warning") + elif zugang.verfahren: + flash( + "Angebotene Anmeldeverfahren: " + + ", ".join(vnc.verfahren_name(v) for v in zugang.verfahren), + "info", + ) + else: + flash(f"{zugang.ziel}:{port} hat nicht geantwortet.", "danger") + return redirect(url_for("screen.screen", device_id=device_id)) + + def init_app(app) -> None: # type: ignore[no-untyped-def] """Meldet Seite und -- falls verfuegbar -- die WebSocket-Strecke an.""" app.register_blueprint(bp) diff --git a/apps/tesm/src/tesm/services/vnc.py b/apps/tesm/src/tesm/services/vnc.py index cd60553..f644fc7 100644 --- a/apps/tesm/src/tesm/services/vnc.py +++ b/apps/tesm/src/tesm/services/vnc.py @@ -325,56 +325,32 @@ def zugang(device: Mapping[str, Any], *, port: int = STANDARD_PORT, pruefen: boo ) -#: So lange gilt eine Messung der Anmeldeverfahren als frisch. -#: -#: Sie kostet eine angefangene Protokollverhandlung, und ein RealVNC Server -#: zaehlt abgebrochene Versuche. Wer eine Seite dreimal neu laedt, soll dem -#: Geraet nicht dreimal als Fehlversuch erscheinen. -MESSUNG_GILT = 300.0 +def zugang_gemerkt(device: Mapping[str, Any], *, port: int = STANDARD_PORT) -> Zugang: + """Der Zugang, wie er in der Datenbank steht -- **ohne** das Geraet zu fragen. + Frueher hat diese Funktion selbst gemessen, wenn die letzte Messung alt war. + Das ist gestrichen, und zwar aus Erfahrung: jede Messung ist eine + angefangene und abgebrochene Protokollverhandlung, und ein RealVNC Server + zaehlt so etwas. Wer eine Bildschirmseite oeffnet, klopfte damit zweimal an + -- einmal zum Messen, einmal fuer die Sitzung --, und das zweite Klopfen + fiel dem ersten zum Opfer. -def zugang_gemerkt( - device: Mapping[str, Any], *, port: int = STANDARD_PORT, pruefen: bool = True -) -> tuple[Zugang, bool]: - """Der Zugang -- aus der letzten Messung, wenn die frisch genug ist. - - Gibt ``(Zugang, frisch_gemessen)`` zurueck; das zweite sagt dem Aufrufer, - ob er das Ergebnis festhalten muss. + Jetzt beruehrt das Oeffnen einer Seite das Geraet gar nicht mehr. Die + Erreichbarkeit kommt aus dem Ueberwachungsdurchlauf (ein blosser + Verbindungsaufbau), die Anmeldeverfahren aus der letzten **ausdruecklichen** + Pruefung -- der Knopf dafuer steht auf der Seite und wird gedrueckt, wenn + etwas nicht geht. """ - host = ziel(device) - if not pruefen: - return Zugang(ziel=host, port=port, erreichbar=False), False - - gemessen_am = str(device.get("vnc_types_at") or "") - if gemessen_am and _juenger_als(gemessen_am, MESSUNG_GILT): - gespeichert = str(device.get("vnc_types") or "") - verfahren = tuple( + gespeichert = str(device.get("vnc_types") or "") + return Zugang( + ziel=ziel(device), + port=port, + erreichbar=bool(device.get("vnc_open")), + verfahren=tuple( int(teil) for teil in gespeichert.split(",") if teil.strip().isdigit() - ) - return ( - Zugang( - ziel=host, - port=port, - erreichbar=bool(device.get("vnc_open")), - verfahren=verfahren, - absage=str(device.get("vnc_reason") or ""), - ), - False, - ) - return zugang(device, port=port), True - - -def _juenger_als(zeitpunkt: str, sekunden: float) -> bool: - """Ist dieser ISO-Zeitstempel juenger als die Frist?""" - from datetime import datetime, timezone - - try: - moment = datetime.fromisoformat(zeitpunkt) - except ValueError: - return False - if moment.tzinfo is None: - moment = moment.replace(tzinfo=timezone.utc) - return (datetime.now(timezone.utc) - moment).total_seconds() < sekunden + ), + absage=str(device.get("vnc_reason") or ""), + ) def verbindungsdatei( diff --git a/apps/tesm/src/tesm/templates/tesm/screen.html b/apps/tesm/src/tesm/templates/tesm/screen.html index 28298c7..1b890f5 100644 --- a/apps/tesm/src/tesm/templates/tesm/screen.html +++ b/apps/tesm/src/tesm/templates/tesm/screen.html @@ -40,8 +40,11 @@ Tastatur und Maus gehen an das Gerät, sobald das Bild steht. Die Zwischenablage bleibt getrennt. {% if zugang.verfahren %} - Angebotene Anmeldeverfahren: + Zuletzt angebotene Anmeldeverfahren: {% for nummer in zugang.verfahren %}{{ verfahren_name(nummer) }}{% if not loop.last %}, {% endif %}{% endfor %}. + {% else %} + Welche Anmeldeverfahren das Gerät anbietet, ist nicht bekannt – die Seite + fragt es nicht von sich aus. {% endif %} @@ -49,6 +52,17 @@ + {# Ausdrücklich ein Knopf und keine Nebenwirkung des Seitenaufbaus: die + Prüfung bricht eine Protokollverhandlung ab, und RealVNC zählt das. + Wer sucht, drückt ihn; wer nur arbeiten will, merkt nichts davon. #} +
+ + +
{{ icon('download', size=14) }} Verbindungsdatei diff --git a/packages/tesm-core/pyproject.toml b/packages/tesm-core/pyproject.toml index f4c5c4e..3f4366b 100644 --- a/packages/tesm-core/pyproject.toml +++ b/packages/tesm-core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-core" -version = "2.8.7" +version = "2.8.8" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = [ diff --git a/packages/tesm-core/src/tesm_core/static/tesm_core/js/screen.js b/packages/tesm-core/src/tesm_core/static/tesm_core/js/screen.js index 99f85f8..bbff37c 100644 --- a/packages/tesm-core/src/tesm_core/static/tesm_core/js/screen.js +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/js/screen.js @@ -11,25 +11,18 @@ import RFB from "../vendor/novnc/core/rfb.js"; -// Sicherheitsverfahren, die ohne Zugangsdaten auskommen. Bietet der Server -// eines davon an und haben wir keine, nehmen wir es -- auch wenn der Server -// ein anderes zuerst nennt. +// Zwei Eingriffe in private Methoden der mitgelieferten Bibliothek -- beide +// nur, um zu *erfahren*, was schiefging; keiner greift in die Verhandlung ein. +// Bewusst hier und nicht in der Bibliothek selbst: eine neue Fassung von noVNC +// laesst sich dann austauschen, ohne eine Aenderung nachzupflegen -- faellt +// eine Methode weg, entfaellt nur die Auskunft, nicht die Sitzung. // -// Warum das noetig ist: noVNC waehlt "das erste, das ich kann, in der -// Reihenfolge des Servers". Ein RealVNC Server nennt RA2ne vor "ohne -// Authentifizierung"; der Browser liefe also in eine Passwortabfrage, obwohl -// daneben ein Weg offensteht, den auch der RealVNC Viewer nimmt. Wer ein -// Passwort hinterlegt hat, behaelt die Reihenfolge des Servers -- dann ist -// der angemeldete Weg der bessere. -const OHNE_ZUGANGSDATEN = new Set([1]); - -// Drei Eingriffe in private Methoden der mitgelieferten Bibliothek. Bewusst -// hier und nicht in der Bibliothek selbst: eine neue Fassung von noVNC laesst -// sich dann austauschen, ohne eine Aenderung nachzupflegen -- faellt eine -// Methode weg, greift der jeweilige Eingriff nicht mehr, und die Sitzung -// laeuft wieder wie ohne ihn. Kein stiller Bruch. -const istUnterstuetzt = RFB.prototype._isSupportedSecurityType; - +// Es stand hier einmal ein dritter, der die Wahl des Sicherheitsverfahrens +// umbog: ohne hinterlegte Zugangsdaten sollte ein Verfahren ohne Anmeldung +// gewinnen, weil der Server RA2ne zuerst nennt. Gut gemeint und falsch -- +// genau dieser Griff hat eine Sitzung, die lief, zum Abbruch gebracht: der +// RealVNC Server bietet "ohne Authentifizierung" zwar an, legt bei dieser Wahl +// aber auf. Die Reihenfolge des Servers ist seine Entscheidung, nicht unsere. // Den *Grund* eines Fehlschlags kennt nur ``_fail`` -- das Ereignis // "disconnect" traegt ihn nicht, und der Weg ueber die Protokollausgabe // funktioniert nicht: die Bibliothek bindet ``console.error`` beim Laden, also @@ -126,21 +119,6 @@ function starten(wurzel) { letzterGrund = ""; letzterCode = 0; - // Vorauswahl nur, wenn wir nichts anzubieten haben: dann zaehlt der Weg, - // der ohne Zugangsdaten auskommt -- sofern der Server ihn anbietet. - const hatZugangsdaten = Boolean(zugang && (zugang.password || zugang.username)); - const angeboten = (wurzel.dataset.verfahren || "") - .split(",") - .map((wert) => parseInt(wert, 10)) - .filter((wert) => !Number.isNaN(wert)); - const ohnePasswortMoeglich = angeboten.some((wert) => OHNE_ZUGANGSDATEN.has(wert)); - RFB.prototype._isSupportedSecurityType = function (typ) { - if (!hatZugangsdaten && ohnePasswortMoeglich) { - return OHNE_ZUGANGSDATEN.has(typ); - } - return istUnterstuetzt.call(this, typ); - }; - rfb = new RFB(leinwand, adresse, { credentials: zugang, // Eine Bildschirmsitzung soll nicht nebenbei die Zwischenablage des