Version 2.8.17: keine Vorauswahl, keine Messung im Seitenaufbau

This commit is contained in:
alientim committed 2026-09-13 13:07:05 +02:00
1 parent b54b87ca6f
commit 491724beb5
7 files changed
+103 -114

No files matched your search

+1 -1
View File
@@ -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 = [
+7 -15
View File
@@ -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("/<int:device_id>/vnc")
+46 -17
View File
@@ -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/<int:device_id>/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)
+22 -46
View File
@@ -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(
+15 -1
View File
@@ -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 %}
</div>
</div>
@@ -49,6 +52,17 @@
<button class="btn btn--sm" type="button" id="screen-cad" hidden
title="Strg+Alt+Entf an das Gerät senden">Strg+Alt+Entf</button>
<button class="btn btn--sm" type="button" id="screen-disconnect" hidden>Trennen</button>
{# 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. #}
<form method="post" data-no-guard
action="{{ url_for('screen.probe', device_id=device.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost" type="submit"
title="Fragt das Gerät, welche Anmeldeverfahren es anbietet">
Verfahren prüfen
</button>
</form>
<a class="btn btn--sm btn--ghost"
href="{{ url_for('devices.vnc_connection', device_id=device.id) }}">
{{ icon('download', size=14) }} Verbindungsdatei
+1 -1
View File
@@ -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 = [
@@ -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