Compare commits

..
2 Commits
10 changed files with 236 additions and 93 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm" name = "tesm"
version = "2.8.15" version = "2.8.17"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+7 -12
View File
@@ -374,13 +374,13 @@ def detail(device_id: int):
def _vnc_zugang(conn: Any, device: dict[str, Any]) -> vnc.Zugang | None: 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 Diese Seite fragt das Geraet **nicht**. Erreichbarkeit kommt aus dem
Ueberwachungsdurchlauf; hier geht es um **ein** Geraet, also fragt die Seite Ueberwachungsdurchlauf, die Anmeldeverfahren aus der letzten
selbst nach (ein halbe Sekunde Zeitlimit, und nur wenn der Client gerade ausdruecklichen Pruefung. Der Grund steht in :func:`vnc.zugang_gemerkt`:
erreichbar ist). Das Ergebnis wird gleich festgehalten -- dann stimmt auch jede Messung ist eine angefangene Protokollverhandlung, und ein RealVNC
die Kachel, ohne auf den naechsten Durchlauf zu warten. Server zaehlt so etwas mit.
""" """
extension = core() extension = core()
if not current_user.has("device_vnc.execute"): if not current_user.has("device_vnc.execute"):
@@ -388,12 +388,7 @@ def _vnc_zugang(conn: Any, device: dict[str, Any]) -> vnc.Zugang | None:
if not extension.settings.get(conn, "vnc_enabled"): if not extension.settings.get(conn, "vnc_enabled"):
return None return None
port = int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT) 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") return vnc.zugang_gemerkt(device, port=port)
zugang = vnc.zugang(device, port=port, pruefen=online)
if online:
with transaction():
monitor.merke_vnc(conn, int(device["id"]), zugang.erreichbar, zugang.ziel)
return zugang
@bp.get("/<int:device_id>/vnc") @bp.get("/<int:device_id>/vnc")
+46 -7
View File
@@ -30,7 +30,7 @@ import threading
import time import time
from typing import Any 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 import audit
from tesm_core.auth.models import current_user, permission_required, reauth_required from tesm_core.auth.models import current_user, permission_required, reauth_required
from tesm_core.auth.service import client_ip, reauth_is_fresh 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): def screen(device_id: int):
"""Die Seite mit der Leinwand. """Die Seite mit der Leinwand.
Sie misst vor dem Verbinden, **wie** der VNC-Server sich authentifizieren Sie beruehrt das Geraet nicht: die Sitzung selbst ist der einzige Anlass,
will. Bietet er nur Verfahren an, die der Browser-Client nicht kennt, steht dort anzuklopfen. Was ueber die Anmeldeverfahren bekannt ist, steht in der
das als Satz auf der Seite -- mit der Zeile, die das auf dem Geraet aendert. Datenbank -- aus der letzten ausdruecklichen Pruefung (Knopf "Verfahren
Der Unterschied zur schwarzen Flaeche ist der ganze Punkt: wer nicht weiss, pruefen"). Dass diese Seite frueher selbst gemessen hat, war ein Fehler:
woran es liegt, sucht den Fehler bei TESM. 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() conn = db()
device = _geraet(conn, device_id) device = _geraet(conn, device_id)
@@ -85,7 +87,7 @@ def screen(device_id: int):
if not port: if not port:
abort(404) abort(404)
zugang = vnc.zugang(device, port=port, pruefen=str(device.get("state") or "") == "online") zugang = vnc.zugang_gemerkt(device, port=port)
benutzer, passwort = "", "" benutzer, passwort = "", ""
darf_geheimnis = bool(current_user.has("credentials.secrets")) darf_geheimnis = bool(current_user.has("credentials.secrets"))
if device.get("credential_id") and darf_geheimnis: if device.get("credential_id") and darf_geheimnis:
@@ -122,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] def init_app(app) -> None: # type: ignore[no-untyped-def]
"""Meldet Seite und -- falls verfuegbar -- die WebSocket-Strecke an.""" """Meldet Seite und -- falls verfuegbar -- die WebSocket-Strecke an."""
app.register_blueprint(bp) app.register_blueprint(bp)
+14
View File
@@ -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] def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
"""Aus sieben Standardgruppen werden drei -- ohne jemandem etwas wegzunehmen. """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=10, name="vnc-erreichbarkeit", sql=V10),
Migration(version=11, name="vnc-ziel", sql=V11), Migration(version=11, name="vnc-ziel", sql=V11),
Migration(version=12, name="vnc-verfahren", sql=V12), Migration(version=12, name="vnc-verfahren", sql=V12),
Migration(version=13, name="vnc-absage", sql=V13),
) )
+2 -1
View File
@@ -544,7 +544,8 @@ def list_devices(
st.last_ok_at, st.last_change_at, st.last_restart_at, st.restart_count, 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, 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_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 FROM devices d
LEFT JOIN switches s ON s.id = d.switch_id AND s.deleted_at IS NULL 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 LEFT JOIN credentials c ON c.id = d.credential_id AND c.deleted_at IS NULL
+28 -24
View File
@@ -507,30 +507,36 @@ class SweepReport:
duration_ms: int = 0 duration_ms: int = 0
def merke_vnc( def merke_vnc(conn: sqlite3.Connection, device_id: int, offen: bool, ziel: str = "") -> None:
conn: sqlite3.Connection,
device_id: int,
offen: bool,
ziel: str = "",
verfahren: tuple[int, ...] = (),
) -> None:
"""Haelt fest, ob dort ein VNC-Server antwortet -- und unter welchem Ziel. """Haelt fest, ob dort ein VNC-Server antwortet -- und unter welchem Ziel.
Das Ziel gehoert dazu, weil die Statusuebersicht die Verbindung unmittelbar Das Ziel gehoert dazu, weil die Statusuebersicht die Verbindung unmittelbar
verlinkt. Es aus dem Namen neu herzuleiten hiesse, je Kachel und verlinkt. Es aus dem Namen neu herzuleiten hiesse, je Kachel und
Seitenaufbau eine Namensaufloesung zu versuchen; hier steht es einmal Seitenaufbau eine Namensaufloesung zu versuchen; hier steht es einmal
geschrieben, von dem, der ohnehin gefragt hat. 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( conn.execute(
"UPDATE device_status SET vnc_open=?, vnc_checked_at=?, vnc_target=?, vnc_types=? " "UPDATE device_status SET vnc_open=?, vnc_checked_at=?, vnc_target=? WHERE device_id=?",
"WHERE device_id=?", (1 if offen else 0, _now(), ziel, device_id),
( )
1 if offen else 0,
_now(),
ziel, def merke_verfahren(
",".join(str(v) for v in verfahren), conn: sqlite3.Connection, device_id: int, verfahren: tuple[int, ...], absage: str = ""
device_id, ) -> 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 erreichbare Geraet: nimmt dort ein VNC-Server Verbindungen an? Sie gehoert
hierher und nicht in den Seitenaufbau -- einmal je Intervall statt einmal hierher und nicht in den Seitenaufbau -- einmal je Intervall statt einmal
je Aufruf, und nur fuer Geraete, die ohnehin gerade antworten. 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 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 # bekannten Stand stehen. Ein Geraet, das gerade neu startet, soll
# seinen Knopf nicht verlieren. # seinen Knopf nicht verlieren.
return device, ergebnis, None 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: with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool:
results = list(pool.map(pruefen, devices)) results = list(pool.map(pruefen, devices))
@@ -587,13 +597,7 @@ def run_sweep(
report.checked += 1 report.checked += 1
previous, current = record_status(conn, int(device["id"]), result) previous, current = record_status(conn, int(device["id"]), result)
if vnc_offen is not None: if vnc_offen is not None:
merke_vnc( merke_vnc(conn, int(device["id"]), vnc_offen.erreichbar, vnc_offen.ziel)
conn,
int(device["id"]),
vnc_offen.erreichbar,
vnc_offen.ziel,
vnc_offen.verfahren,
)
if result.ok: if result.ok:
report.online += 1 report.online += 1
else: else:
+109 -12
View File
@@ -61,6 +61,9 @@ class Zugang:
#: gemessen). Daraus entsteht der Satz, warum eine Sitzung im Browser #: gemessen). Daraus entsteht der Satz, warum eine Sitzung im Browser
#: gehen wird oder nicht. #: gehen wird oder nicht.
verfahren: tuple[int, ...] = () 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 @property
def browsertauglich(self) -> bool: def browsertauglich(self) -> bool:
@@ -72,6 +75,14 @@ class Zugang:
"""Warum eine Sitzung im Browser nicht zustande kaeme -- im Klartext.""" """Warum eine Sitzung im Browser nicht zustande kaeme -- im Klartext."""
if not self.erreichbar: if not self.erreichbar:
return f"Auf {self.ziel}:{self.port} antwortet kein VNC-Server." 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: if not self.verfahren:
return "" return ""
if self.browsertauglich: if self.browsertauglich:
@@ -181,7 +192,36 @@ def verfahren_name(nummer: int) -> str:
return str(nummer) 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. """Fragt den Server, **wie** er sich authentifizieren will.
Der Anfang einer VNC-Verbindung ist fuer jeden Server derselbe: er nennt 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 Das ist die Angabe, an der eine Bildschirmsitzung im Browser scheitert oder
gelingt, und sie hier zu messen ist der Unterschied zwischen einer gelingt, und sie hier zu messen ist der Unterschied zwischen einer
schwarzen Flaeche und dem Satz "dieser Server bietet nur Verfahren an, die schwarzen Flaeche und dem Satz "dieser Server bietet nur Verfahren an, die
der Browser nicht kennt". Gibt ``(Fassung, Verfahren)`` zurueck; bei einem der Browser nicht kennt".
Fehler ``("", ())``.
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 import socket
@@ -202,7 +246,7 @@ def sicherheitsverfahren(host: str, port: int = STANDARD_PORT) -> tuple[str, tup
sock.settimeout(ZEITLIMIT) sock.settimeout(ZEITLIMIT)
gruss = sock.recv(12) gruss = sock.recv(12)
if not gruss.startswith(b"RFB "): if not gruss.startswith(b"RFB "):
return "", () return "", (), ""
fassung = gruss[4:11].decode("ascii", "replace") fassung = gruss[4:11].decode("ascii", "replace")
# Wir antworten mit 3.8 -- der Fassung, die auch der Browser # Wir antworten mit 3.8 -- der Fassung, die auch der Browser
# spricht. Ein Server, der mehr kann, faellt darauf zurueck und # 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 break
rohwert += stueck rohwert += stueck
if len(rohwert) < 4: if len(rohwert) < 4:
return fassung, () return fassung, (), ""
bestimmt = int.from_bytes(rohwert, "big") 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) anzahl = sock.recv(1)
if not anzahl: if not anzahl:
return fassung, () return fassung, (), ""
wie_viele = anzahl[0] wie_viele = anzahl[0]
if wie_viele == 0: if wie_viele == 0:
# Null heisst: der Server lehnt ab und schickt den Grund. # Null heisst: der Server lehnt ab und schickt den Grund.
return fassung, () return fassung, (), _absage(sock)
liste = b"" liste = b""
while len(liste) < wie_viele: while len(liste) < wie_viele:
stueck = sock.recv(wie_viele - len(liste)) stueck = sock.recv(wie_viele - len(liste))
if not stueck: if not stueck:
break break
liste += stueck liste += stueck
return fassung, tuple(liste) return fassung, tuple(liste), ""
except OSError: 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: def zugang(device: Mapping[str, Any], *, port: int = STANDARD_PORT, pruefen: bool = True) -> Zugang:
@@ -252,8 +315,42 @@ def zugang(device: Mapping[str, Any], *, port: int = STANDARD_PORT, pruefen: boo
host = ziel(device) host = ziel(device)
if not pruefen: if not pruefen:
return Zugang(ziel=host, port=port, erreichbar=False) return Zugang(ziel=host, port=port, erreichbar=False)
fassung, verfahren = sicherheitsverfahren(host, port) fassung, verfahren, absage = sicherheitsverfahren(host, port)
return Zugang(ziel=host, port=port, erreichbar=bool(fassung), verfahren=verfahren) return Zugang(
ziel=host,
port=port,
erreichbar=bool(fassung),
verfahren=verfahren,
absage=absage,
)
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.
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.
"""
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()
),
absage=str(device.get("vnc_reason") or ""),
)
def verbindungsdatei( def verbindungsdatei(
+17 -2
View File
@@ -30,7 +30,8 @@
<div class="flex-1"> <div class="flex-1">
<div class="card__title"> <div class="card__title">
{{ icon('monitor', size=17) }} {{ device.name }} {{ 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') }} {% if zugang.browsertauglich %}{{ pill('Browser-Sitzung möglich', 'online') }}
{% else %}{{ pill('Verfahren nicht unterstützt', 'warning') }}{% endif %} {% else %}{{ pill('Verfahren nicht unterstützt', 'warning') }}{% endif %}
{% endif %} {% endif %}
@@ -39,8 +40,11 @@
Tastatur und Maus gehen an das Gerät, sobald das Bild steht. Die Zwischenablage Tastatur und Maus gehen an das Gerät, sobald das Bild steht. Die Zwischenablage
bleibt getrennt. bleibt getrennt.
{% if zugang.verfahren %} {% if zugang.verfahren %}
Angebotene Anmeldeverfahren: Zuletzt angebotene Anmeldeverfahren:
{% for nummer in zugang.verfahren %}{{ verfahren_name(nummer) }}{% if not loop.last %}, {% endif %}{% endfor %}. {% 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 %} {% endif %}
</div> </div>
</div> </div>
@@ -48,6 +52,17 @@
<button class="btn btn--sm" type="button" id="screen-cad" hidden <button class="btn btn--sm" type="button" id="screen-cad" hidden
title="Strg+Alt+Entf an das Gerät senden">Strg+Alt+Entf</button> 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> <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" <a class="btn btn--sm btn--ghost"
href="{{ url_for('devices.vnc_connection', device_id=device.id) }}"> href="{{ url_for('devices.vnc_connection', device_id=device.id) }}">
{{ icon('download', size=14) }} Verbindungsdatei {{ icon('download', size=14) }} Verbindungsdatei
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" 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" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -11,25 +11,18 @@
import RFB from "../vendor/novnc/core/rfb.js"; import RFB from "../vendor/novnc/core/rfb.js";
// Sicherheitsverfahren, die ohne Zugangsdaten auskommen. Bietet der Server // Zwei Eingriffe in private Methoden der mitgelieferten Bibliothek -- beide
// eines davon an und haben wir keine, nehmen wir es -- auch wenn der Server // nur, um zu *erfahren*, was schiefging; keiner greift in die Verhandlung ein.
// ein anderes zuerst nennt. // 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 // Es stand hier einmal ein dritter, der die Wahl des Sicherheitsverfahrens
// Reihenfolge des Servers". Ein RealVNC Server nennt RA2ne vor "ohne // umbog: ohne hinterlegte Zugangsdaten sollte ein Verfahren ohne Anmeldung
// Authentifizierung"; der Browser liefe also in eine Passwortabfrage, obwohl // gewinnen, weil der Server RA2ne zuerst nennt. Gut gemeint und falsch --
// daneben ein Weg offensteht, den auch der RealVNC Viewer nimmt. Wer ein // genau dieser Griff hat eine Sitzung, die lief, zum Abbruch gebracht: der
// Passwort hinterlegt hat, behaelt die Reihenfolge des Servers -- dann ist // RealVNC Server bietet "ohne Authentifizierung" zwar an, legt bei dieser Wahl
// der angemeldete Weg der bessere. // aber auf. Die Reihenfolge des Servers ist seine Entscheidung, nicht unsere.
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;
// Den *Grund* eines Fehlschlags kennt nur ``_fail`` -- das Ereignis // Den *Grund* eines Fehlschlags kennt nur ``_fail`` -- das Ereignis
// "disconnect" traegt ihn nicht, und der Weg ueber die Protokollausgabe // "disconnect" traegt ihn nicht, und der Weg ueber die Protokollausgabe
// funktioniert nicht: die Bibliothek bindet ``console.error`` beim Laden, also // funktioniert nicht: die Bibliothek bindet ``console.error`` beim Laden, also
@@ -126,21 +119,6 @@ function starten(wurzel) {
letzterGrund = ""; letzterGrund = "";
letzterCode = 0; 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, { rfb = new RFB(leinwand, adresse, {
credentials: zugang, credentials: zugang,
// Eine Bildschirmsitzung soll nicht nebenbei die Zwischenablage des // Eine Bildschirmsitzung soll nicht nebenbei die Zwischenablage des