From b54b87ca6fef17c7975165e69f23cd3ae6c2e2be Mon Sep 17 00:00:00 2001 From: Tim Eertmoed Date: Sun, 13 Sep 2026 12:22:31 +0200 Subject: [PATCH] Version 2.8.16: die Ueberwachung sperrt sich nicht mehr selbst aus --- apps/tesm/pyproject.toml | 2 +- apps/tesm/src/tesm/blueprints/devices.py | 7 +- apps/tesm/src/tesm/blueprints/screen.py | 12 +- apps/tesm/src/tesm/schema.py | 14 ++ apps/tesm/src/tesm/services/inventory.py | 3 +- apps/tesm/src/tesm/services/monitor.py | 52 ++++--- apps/tesm/src/tesm/services/vnc.py | 145 ++++++++++++++++-- apps/tesm/src/tesm/templates/tesm/screen.html | 3 +- 8 files changed, 196 insertions(+), 42 deletions(-) diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index a90e710..d1ccc6f 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.15" +version = "2.8.16" 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 eae2e55..9c5c410 100644 --- a/apps/tesm/src/tesm/blueprints/devices.py +++ b/apps/tesm/src/tesm/blueprints/devices.py @@ -389,10 +389,13 @@ def _vnc_zugang(conn: Any, device: dict[str, Any]) -> vnc.Zugang | None: 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 = vnc.zugang(device, port=port, pruefen=online) - if online: + 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 diff --git a/apps/tesm/src/tesm/blueprints/screen.py b/apps/tesm/src/tesm/blueprints/screen.py index ec9ad04..c72f752 100644 --- a/apps/tesm/src/tesm/blueprints/screen.py +++ b/apps/tesm/src/tesm/blueprints/screen.py @@ -85,7 +85,17 @@ def screen(device_id: int): if not port: abort(404) - zugang = vnc.zugang(device, port=port, pruefen=str(device.get("state") or "") == "online") + 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 + ) benutzer, passwort = "", "" darf_geheimnis = bool(current_user.has("credentials.secrets")) if device.get("credential_id") and darf_geheimnis: diff --git a/apps/tesm/src/tesm/schema.py b/apps/tesm/src/tesm/schema.py index c39d9ef..a8bacbc 100644 --- a/apps/tesm/src/tesm/schema.py +++ b/apps/tesm/src/tesm/schema.py @@ -526,6 +526,19 @@ ALTER TABLE device_status ADD COLUMN vnc_types TEXT NOT NULL DEFAULT ''; """ +# Version 13 ---------------------------------------------------------------- +# +# Die Absage eines VNC-Servers und der Zeitpunkt der Verfahrensmessung. Beide +# gehoeren getrennt vom Ueberwachungsdurchlauf gefuehrt: der fragt nur noch, +# ob der Port annimmt (eine abgebrochene Protokollverhandlung sperrt bei +# RealVNC die eigene Adresse aus), die Verfahren misst die Seite -- und auch +# die nur, wenn die letzte Messung alt ist. +V13 = """ +ALTER TABLE device_status ADD COLUMN vnc_reason TEXT NOT NULL DEFAULT ''; +ALTER TABLE device_status ADD COLUMN vnc_types_at TEXT; +""" + + def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def] """Aus sieben Standardgruppen werden drei -- ohne jemandem etwas wegzunehmen. @@ -571,4 +584,5 @@ TESM_MIGRATIONS: tuple[Migration, ...] = ( Migration(version=10, name="vnc-erreichbarkeit", sql=V10), Migration(version=11, name="vnc-ziel", sql=V11), Migration(version=12, name="vnc-verfahren", sql=V12), + Migration(version=13, name="vnc-absage", sql=V13), ) diff --git a/apps/tesm/src/tesm/services/inventory.py b/apps/tesm/src/tesm/services/inventory.py index 554ffaf..70e1f63 100644 --- a/apps/tesm/src/tesm/services/inventory.py +++ b/apps/tesm/src/tesm/services/inventory.py @@ -544,7 +544,8 @@ def list_devices( st.last_ok_at, st.last_change_at, st.last_restart_at, st.restart_count, st.fail_count, COALESCE(st.vnc_open, 0) AS vnc_open, st.vnc_checked_at, COALESCE(st.vnc_target, '') AS vnc_target, - COALESCE(st.vnc_types, '') AS vnc_types + COALESCE(st.vnc_types, '') AS vnc_types, + COALESCE(st.vnc_reason, '') AS vnc_reason, st.vnc_types_at FROM devices d LEFT JOIN switches s ON s.id = d.switch_id AND s.deleted_at IS NULL LEFT JOIN credentials c ON c.id = d.credential_id AND c.deleted_at IS NULL diff --git a/apps/tesm/src/tesm/services/monitor.py b/apps/tesm/src/tesm/services/monitor.py index a73bc3f..36d6490 100644 --- a/apps/tesm/src/tesm/services/monitor.py +++ b/apps/tesm/src/tesm/services/monitor.py @@ -507,30 +507,36 @@ class SweepReport: duration_ms: int = 0 -def merke_vnc( - conn: sqlite3.Connection, - device_id: int, - offen: bool, - ziel: str = "", - verfahren: tuple[int, ...] = (), -) -> None: +def merke_vnc(conn: sqlite3.Connection, device_id: int, offen: bool, ziel: str = "") -> None: """Haelt fest, ob dort ein VNC-Server antwortet -- und unter welchem Ziel. Das Ziel gehoert dazu, weil die Statusuebersicht die Verbindung unmittelbar verlinkt. Es aus dem Namen neu herzuleiten hiesse, je Kachel und Seitenaufbau eine Namensaufloesung zu versuchen; hier steht es einmal geschrieben, von dem, der ohnehin gefragt hat. + + Die Anmeldeverfahren bleiben unberuehrt: sie kommen aus einer anderen + Quelle (:func:`merke_verfahren`) und haetten hier nur den Wert "nicht + gefragt" -- der wuerde eine gute Messung ueberschreiben. """ conn.execute( - "UPDATE device_status SET vnc_open=?, vnc_checked_at=?, vnc_target=?, vnc_types=? " - "WHERE device_id=?", - ( - 1 if offen else 0, - _now(), - ziel, - ",".join(str(v) for v in verfahren), - device_id, - ), + "UPDATE device_status SET vnc_open=?, vnc_checked_at=?, vnc_target=? WHERE device_id=?", + (1 if offen else 0, _now(), ziel, device_id), + ) + + +def merke_verfahren( + conn: sqlite3.Connection, device_id: int, verfahren: tuple[int, ...], absage: str = "" +) -> None: + """Haelt fest, wie sich der VNC-Server authentifizieren will. + + Mit Zeitstempel, denn diese Messung kostet eine angefangene + Protokollverhandlung -- und die soll sich nicht bei jedem Seitenaufbau + wiederholen. + """ + conn.execute( + "UPDATE device_status SET vnc_types=?, vnc_reason=?, vnc_types_at=? WHERE device_id=?", + (",".join(str(v) for v in verfahren), absage, _now(), device_id), ) @@ -553,6 +559,10 @@ def run_sweep( erreichbare Geraet: nimmt dort ein VNC-Server Verbindungen an? Sie gehoert hierher und nicht in den Seitenaufbau -- einmal je Intervall statt einmal je Aufruf, und nur fuer Geraete, die ohnehin gerade antworten. + + **Nur die Frage nach dem Port**, kein Wort Protokoll: ein RealVNC Server + zaehlt abgebrochene Verhandlungen und sperrt die Adresse eine Weile aus. + Welche Anmeldeverfahren er anbietet, misst die Seite, die es braucht. """ from . import vnc as vnc_dienst # spaet: vnc benutzt tcp_reachable von hier @@ -575,7 +585,7 @@ 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(device, port=vnc_port) + return device, ergebnis, vnc_dienst.zugang_kurz(device, port=vnc_port) with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool: results = list(pool.map(pruefen, devices)) @@ -587,13 +597,7 @@ def run_sweep( report.checked += 1 previous, current = record_status(conn, int(device["id"]), result) if vnc_offen is not None: - merke_vnc( - conn, - int(device["id"]), - vnc_offen.erreichbar, - vnc_offen.ziel, - vnc_offen.verfahren, - ) + merke_vnc(conn, int(device["id"]), vnc_offen.erreichbar, vnc_offen.ziel) if result.ok: report.online += 1 else: diff --git a/apps/tesm/src/tesm/services/vnc.py b/apps/tesm/src/tesm/services/vnc.py index b30b57e..cd60553 100644 --- a/apps/tesm/src/tesm/services/vnc.py +++ b/apps/tesm/src/tesm/services/vnc.py @@ -61,6 +61,9 @@ class Zugang: #: gemessen). Daraus entsteht der Satz, warum eine Sitzung im Browser #: gehen wird oder nicht. verfahren: tuple[int, ...] = () + #: Der Text, mit dem der Server die Verbindung abgelehnt hat -- er nennt + #: dann kein Verfahren, sondern eine 0 und einen Grund. + absage: str = "" @property def browsertauglich(self) -> bool: @@ -72,6 +75,14 @@ class Zugang: """Warum eine Sitzung im Browser nicht zustande kaeme -- im Klartext.""" if not self.erreichbar: return f"Auf {self.ziel}:{self.port} antwortet kein VNC-Server." + if self.absage: + return ( + f"Der VNC-Server auf {self.ziel} weist die Verbindung ab: " + f"„{self.absage}“. Das ist seine Auskunft, nicht unsere – " + "typisch sind eine Sperre nach zu vielen Fehlversuchen " + "(RealVNC: BlacklistTimeout), eine Adressfilterung oder eine " + "belegte Sitzung." + ) if not self.verfahren: return "" if self.browsertauglich: @@ -181,7 +192,36 @@ def verfahren_name(nummer: int) -> str: return str(nummer) -def sicherheitsverfahren(host: str, port: int = STANDARD_PORT) -> tuple[str, tuple[int, ...]]: +def _absage(sock: Any) -> str: + """Der Grund, den ein Server einer abgelehnten Verbindung mitgibt. + + Auf eine 0 -- ob als Anzahl (ab 3.7) oder als bestimmtes Verfahren (3.3) -- + folgt eine Laenge und ein Text. Genau dieser Satz ist die Auskunft, die + sonst niemand bekommt: "zu viele Fehlversuche", "Verbindung von dieser + Adresse nicht erlaubt", "kein gemeinsames Verfahren". Ohne ihn steht da + nur, dass es nicht geht. + """ + try: + roh = sock.recv(4) + if len(roh) < 4: + return "" + laenge = int.from_bytes(roh, "big") + if not 0 < laenge <= 1024: + return "" + text = b"" + while len(text) < laenge: + stueck = sock.recv(laenge - len(text)) + if not stueck: + break + text += stueck + return text.decode("utf-8", "replace").strip() + except OSError: + return "" + + +def sicherheitsverfahren( + host: str, port: int = STANDARD_PORT +) -> tuple[str, tuple[int, ...], str]: """Fragt den Server, **wie** er sich authentifizieren will. Der Anfang einer VNC-Verbindung ist fuer jeden Server derselbe: er nennt @@ -192,8 +232,12 @@ def sicherheitsverfahren(host: str, port: int = STANDARD_PORT) -> tuple[str, tup Das ist die Angabe, an der eine Bildschirmsitzung im Browser scheitert oder gelingt, und sie hier zu messen ist der Unterschied zwischen einer schwarzen Flaeche und dem Satz "dieser Server bietet nur Verfahren an, die - der Browser nicht kennt". Gibt ``(Fassung, Verfahren)`` zurueck; bei einem - Fehler ``("", ())``. + der Browser nicht kennt". + + Gibt ``(Fassung, Verfahren, Absage)`` zurueck. Die Absage ist der Text, den + ein Server mitschickt, wenn er die Verbindung **ablehnt** -- er meldet dann + kein Verfahren, sondern eine 0 und einen Grund. Bei einem Fehler + ``("", (), "")``. """ import socket @@ -202,7 +246,7 @@ def sicherheitsverfahren(host: str, port: int = STANDARD_PORT) -> tuple[str, tup sock.settimeout(ZEITLIMIT) gruss = sock.recv(12) if not gruss.startswith(b"RFB "): - return "", () + return "", (), "" fassung = gruss[4:11].decode("ascii", "replace") # Wir antworten mit 3.8 -- der Fassung, die auch der Browser # spricht. Ein Server, der mehr kann, faellt darauf zurueck und @@ -225,26 +269,45 @@ def sicherheitsverfahren(host: str, port: int = STANDARD_PORT) -> tuple[str, tup break rohwert += stueck if len(rohwert) < 4: - return fassung, () + return fassung, (), "" bestimmt = int.from_bytes(rohwert, "big") - return fassung, () if bestimmt == 0 else (bestimmt,) + if bestimmt == 0: + return fassung, (), _absage(sock) + return fassung, (bestimmt,), "" anzahl = sock.recv(1) if not anzahl: - return fassung, () + return fassung, (), "" wie_viele = anzahl[0] if wie_viele == 0: # Null heisst: der Server lehnt ab und schickt den Grund. - return fassung, () + return fassung, (), _absage(sock) liste = b"" while len(liste) < wie_viele: stueck = sock.recv(wie_viele - len(liste)) if not stueck: break liste += stueck - return fassung, tuple(liste) + return fassung, tuple(liste), "" except OSError: - return "", () + return "", (), "" + + +def zugang_kurz(device: Mapping[str, Any], *, port: int = STANDARD_PORT) -> Zugang: + """Nur die Frage, ob dort jemand annimmt -- ohne ein Wort Protokoll. + + Das ist die Fassung fuer den Ueberwachungsdurchlauf. Er laeuft alle paar + Minuten ueber jedes Geraet, und eine angefangene und sofort wieder + abgebrochene Protokollverhandlung ist nicht nur unhoeflich: ein RealVNC + Server zaehlt abgebrochene Versuche und sperrt die Adresse eine Weile aus + (``BlacklistTimeout``). Eine Ueberwachung, die ihr eigenes Ziel aussperrt, + waere ein schoener Eigentor -- also fragt sie nur, ob der Port annimmt. + + Welche Anmeldeverfahren der Server kann, misst erst die Seite, die es + wirklich braucht -- und auch die nicht oefter als noetig. + """ + host = ziel(device) + return Zugang(ziel=host, port=port, erreichbar=erreichbar(host, port)) def zugang(device: Mapping[str, Any], *, port: int = STANDARD_PORT, pruefen: bool = True) -> Zugang: @@ -252,8 +315,66 @@ def zugang(device: Mapping[str, Any], *, port: int = STANDARD_PORT, pruefen: boo host = ziel(device) if not pruefen: return Zugang(ziel=host, port=port, erreichbar=False) - fassung, verfahren = sicherheitsverfahren(host, port) - return Zugang(ziel=host, port=port, erreichbar=bool(fassung), verfahren=verfahren) + fassung, verfahren, absage = sicherheitsverfahren(host, port) + return Zugang( + ziel=host, + port=port, + erreichbar=bool(fassung), + verfahren=verfahren, + absage=absage, + ) + + +#: 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, 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. + """ + 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( + 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 def verbindungsdatei( diff --git a/apps/tesm/src/tesm/templates/tesm/screen.html b/apps/tesm/src/tesm/templates/tesm/screen.html index eb7d377..28298c7 100644 --- a/apps/tesm/src/tesm/templates/tesm/screen.html +++ b/apps/tesm/src/tesm/templates/tesm/screen.html @@ -30,7 +30,8 @@
{{ icon('monitor', size=17) }} {{ device.name }} - {% if zugang.verfahren %} + {% if zugang.absage %}{{ pill('Verbindung abgelehnt', 'warning') }} + {% elif zugang.verfahren %} {% if zugang.browsertauglich %}{{ pill('Browser-Sitzung möglich', 'online') }} {% else %}{{ pill('Verfahren nicht unterstützt', 'warning') }}{% endif %} {% endif %}