Compare commits

..
6 Commits
17 changed files with 598 additions and 91 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm"
version = "2.8.13"
version = "2.8.19"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11"
dependencies = [
+20 -19
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,12 +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 = vnc.zugang(device, port=port, pruefen=online)
if online:
with transaction():
monitor.merke_vnc(conn, int(device["id"]), zugang.erreichbar, zugang.ziel)
return zugang
return vnc.zugang_gemerkt(device, port=port)
@bp.get("/<int:device_id>/vnc")
@@ -416,15 +411,21 @@ def vnc_connection(device_id: int):
ziel = vnc.ziel(device)
if not ziel:
abort(404)
with transaction():
_audit(
conn,
"device.vnc_opened",
str(device["name"]),
f"Verbindungsdatei für {ziel}:{port}",
# Kein Eintrag in der Aenderungskette: eine Verbindungsdatei ist derselbe
# Vorgang wie die Sitzung im Browser, nur fuer einen Viewer auf dem
# Arbeitsplatz -- und sie aendert nichts. Wer zusieht, steht im laufenden
# Protokoll; das Aenderungsprotokoll bleibt die Liste der Eingriffe.
live = core().registry.get("live_log")
if live is not None:
live.info(
"vnc", f"{current_user.username} lädt die Verbindungsdatei für {device['name']}"
)
return Response(
vnc.verbindungsdatei(device, port=port),
vnc.verbindungsdatei(
device,
port=port,
warnung_aus=bool(extension.settings.get(conn, "vnc_warn_unencrypted_off")),
),
mimetype="application/octet-stream",
headers={
"Content-Disposition": f'attachment; filename="{vnc.dateiname(device)}"',
+87 -28
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,7 +87,7 @@ def screen(device_id: int):
if not port:
abort(404)
zugang = vnc.zugang(device, port=port, pruefen=str(device.get("state") or "") == "online")
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:
@@ -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]
"""Meldet Seite und -- falls verfuegbar -- die WebSocket-Strecke an."""
app.register_blueprint(bp)
@@ -138,6 +177,26 @@ def init_app(app) -> None: # type: ignore[no-untyped-def]
def screen_socket(ws, device_id: int): # type: ignore[no-untyped-def]
_strecke(ws, device_id)
@sock.route("/ws/probe")
def probe_socket(ws): # type: ignore[no-untyped-def]
"""Sagt "da", und mehr nicht.
Wozu: bricht eine Bildschirmsitzung ab, gibt es zwei sehr verschiedene
Ursachen -- der VNC-Server mag nicht, oder die WebSocket-Verbindung
kommt gar nicht erst durch (ein Proxy zwischen Browser und TESM, der
"Upgrade" nicht weiterreicht, ist der haeufigste Fall). Von aussen
sehen beide gleich aus: ein Abbruch ohne Grund. Diese Probe trennt
sie, ohne dass jemand ein Protokoll lesen muss.
"""
if not current_user.is_authenticated:
ws.close(1008, "Nicht angemeldet.")
return
try:
ws.send("da")
ws.receive(timeout=2)
except Exception: # noqa: BLE001 - der Browser ist weg, das ist keine Stoerung
pass
def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def]
"""Bytes hin, Bytes her -- und sonst nichts.
@@ -172,19 +231,25 @@ def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def]
except OSError as exc:
ws.close(1011, f"{host}:{port} antwortet nicht ({exc}).")
return
# Das Zeitlimit galt dem *Aufbau* -- danach muss es weg.
#
# ``create_connection`` setzt es an den Socket, und dort gilt es fuer jede
# weitere Leseoperation. Ein Bildschirm, auf dem sich gerade nichts tut,
# schickt sekundenlang nichts; nach sechs Sekunden Stille brach das Lesen
# mit einer Zeitueberschreitung ab, und die Sitzung war weg. Genau so
# beobachtet: Bild steht, nach fuenf bis zehn Sekunden Schluss.
gegenstelle.settimeout(None)
# Ins **laufende** Protokoll, nicht in die Aenderungskette: eine
# Bildschirmsitzung aendert nichts an der Anlage. Das Aenderungsprotokoll
# soll beantworten, wer was umgestellt hat -- wer wann zugesehen hat,
# gehoert nicht in dieselbe Liste und macht sie in einem Betrieb mit
# ein paar Dutzend Geraeten unlesbar. Sichtbar ist die Sitzung trotzdem,
# solange sie laeuft.
begonnen = time.monotonic()
with extension.database.transaction(conn):
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="device.screen_opened",
target=str(device["name"]),
detail=f"Bildschirmsitzung zu {host}:{port}",
ip=client_ip(),
severity="warning",
)
live = extension.registry.get("live_log")
if live is not None:
live.info("vnc", f"{current_user.username} öffnet den Bildschirm von {device['name']}")
laeuft = threading.Event()
laeuft.set()
@@ -230,14 +295,8 @@ def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def]
pass
gegenstelle.close()
dauer = int(time.monotonic() - begonnen)
with extension.database.session() as frisch, extension.database.transaction(frisch):
audit.log(
frisch,
actor=current_user.username,
actor_id=current_user.id,
action="device.screen_closed",
target=str(device["name"]),
detail=f"Dauer {dauer}s",
ip=client_ip(),
severity="info",
if live is not None:
live.info(
"vnc",
f"Bildschirmsitzung {device['name']} beendet ({dauer}s)",
)
@@ -105,6 +105,7 @@ def system():
"dashboard_public",
"vnc_enabled",
"vnc_port",
"vnc_warn_unencrypted_off",
"terminal_switches_enabled",
"terminal_clients_enabled",
"terminal_idle_timeout",
@@ -147,6 +148,7 @@ def system_save():
"dashboard_public",
"vnc_enabled",
"vnc_port",
"vnc_warn_unencrypted_off",
"terminal_switches_enabled",
"terminal_clients_enabled",
"terminal_idle_timeout",
+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]
"""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),
)
+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.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
+28 -24
View File
@@ -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:
+143 -12
View File
@@ -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,28 +246,68 @@ 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
# nennt trotzdem seine Verfahren.
sock.sendall(b"RFB 003.008" + bytes([10]))
# Antworten wird mit *seiner* Fassung, hoechstens 3.8. Wer einem
# 3.3-Server mit 3.8 antwortet, bekommt keine Liste, sondern eine
# 32-Bit-Zahl -- und liest sie als "keine Verfahren".
eigene = "003.003" if fassung == "003.003" else "003.008"
sock.sendall(b"RFB " + eigene.encode("ascii") + bytes([10]))
if eigene == "003.003":
# RFB 3.3: der Server bestimmt allein und schickt eine
# 32-Bit-Zahl. Das ist kein Sonderfall aus der Vorzeit --
# RealVNC faellt darauf zurueck, sobald keine Anmeldung
# verlangt wird.
rohwert = b""
while len(rohwert) < 4:
stueck = sock.recv(4 - len(rohwert))
if not stueck:
break
rohwert += stueck
if len(rohwert) < 4:
return fassung, (), ""
bestimmt = int.from_bytes(rohwert, "big")
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:
@@ -231,17 +315,62 @@ 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,
)
def verbindungsdatei(device: Mapping[str, Any], *, port: int = STANDARD_PORT) -> str:
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(
device: Mapping[str, Any], *, port: int = STANDARD_PORT, warnung_aus: bool = False
) -> str:
"""Der Inhalt der ``.vnc``-Datei -- drei Zeilen, keine vierte.
``ConnMethod=tcp`` ist die direkte Verbindung (nicht ueber den Cloud-Dienst
von RealVNC), ``FriendlyName`` der Name, den der Viewer anzeigt, ``Host``
das Ziel. Ein abweichender Port gehoert in dieser Datei an den Host, mit
Doppelpunkt -- einen eigenen Schluessel dafuer gibt es nicht.
``warnung_aus`` haengt eine vierte Zeile an: ``WarnUnencrypted=False``. Sie
betrifft **den Viewer**, nicht den Server -- er zeigt sonst bei jeder
Verbindung zu einem Geraet ohne Streckenverschluesselung einen roten
Hinweis, den man wegklicken muss. Wer ``Encryption=AlwaysOff`` auf dem
Geraet gesetzt hat (damit der Browser ueberhaupt verbinden kann), sieht
diesen Hinweis fortan bei jedem Start. Er bleibt in der Vorgabe stehen:
eine unverschluesselte Strecke soll man einmal bewusst hinnehmen, nicht
stillschweigend. Abschaltbar unter Verwaltung -> System.
"""
host = ziel(device)
if port != STANDARD_PORT:
@@ -251,6 +380,8 @@ def verbindungsdatei(device: Mapping[str, Any], *, port: int = STANDARD_PORT) ->
f"FriendlyName={str(device.get('name') or host).strip()}",
f"Host={host}",
]
if warnung_aus:
zeilen.append("WarnUnencrypted=False")
return ZEILE.join(zeilen) + ZEILE
+10
View File
@@ -66,6 +66,16 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
minimum=1,
maximum=65535,
),
SettingSpec(
"vnc_warn_unencrypted_off",
False,
"bool",
"Warnung des Viewers vor unverschlüsselter Verbindung unterdrücken",
hint="Setzt WarnUnencrypted=False in die Verbindungsdatei. Betrifft nur den "
"RealVNC Viewer auf dem Arbeitsplatz, nicht die Verbindung selbst: die bleibt "
"unverschlüsselt, wenn das Gerät es so anbietet.",
section="monitoring",
),
# -- DHCP --------------------------------------------------------------- #
SettingSpec("dhcp_domain", "local", "str", "Domainname", section="dhcp"),
SettingSpec(
+18 -2
View File
@@ -30,7 +30,8 @@
<div class="flex-1">
<div class="card__title">
{{ 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 %}
@@ -39,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>
@@ -48,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
@@ -74,6 +89,7 @@
<div id="screen" class="screen"
data-socket="{{ websocket_path }}"
data-ziel="{{ zugang.ziel }}:{{ zugang.port }}"
data-verfahren="{{ zugang.verfahren | join(',') }}"
data-blockiert="{{ '1' if zugang.hinderungsgrund else '0' }}"
data-grund="{{ zugang.hinderungsgrund }}">
<div id="screen-canvas" class="screen__canvas" hidden></div>
@@ -53,6 +53,10 @@
{{ field('vnc_port', 'VNC-Port', value=values.vnc_port, type='number',
hint_text=specs['vnc_port'].hint) }}
</div>
{{ checkbox('vnc_warn_unencrypted_off',
'Warnung des Viewers vor unverschlüsselter Verbindung unterdrücken',
checked=values.vnc_warn_unencrypted_off,
hint_text=specs['vnc_warn_unencrypted_off'].hint) }}
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form>
+54
View File
@@ -39,6 +39,7 @@ HTTPS=0
HTTPS_PORT=""
DOMAIN=""
ACME_EMAIL=""
ERZWINGEN=0
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
@@ -49,6 +50,39 @@ warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
info() { printf ' %s\n' "$*"; }
# -- Welche Fassung liegt hier, und welche laeuft schon? ---------------------
#
# Ein Installationslauf auf dieselbe Fassung macht nichts besser: er baut die
# Python-Umgebung neu, kopiert dieselben Dateien, laedt nginx neu und startet
# den Dienst -- mit der einzigen Wirkung, dass die Anwendung ein paar Sekunden
# weg ist. Wer "update.sh" in einen Zeitplan haengt, bekaeme das taeglich.
#
# Verglichen wird die Anwendung **und** der Kern: die beiden Nummern laufen
# absichtlich auseinander (siehe pyproject.toml), und eine Auslieferung kann
# denselben Anwendungsstand mit neuem Kern enthalten.
#: Die Nummer aus einer pyproject.toml. Die erste Zeile "version = ..." gilt --
#: weitere Vorkommen stehen in Abhaengigkeiten und gehen uns nichts an.
paket_version() {
sed -n 's/^version = "\([^"]*\)"/\1/p' "$1" 2>/dev/null | head -1
}
#: Was in dieser Installation laeuft: "<anwendung> <kern>", sonst leer.
laufende_version() {
local wurzel="$1" modul="$2"
[[ -x "$wurzel/venv/bin/python" ]] || return 0
"$wurzel/venv/bin/python" -c \
"import $modul, tesm_core; print($modul.__version__, tesm_core.__version__)" \
2>/dev/null || true
}
#: Ist das, was hier liegt, schon installiert? Eine eigene Funktion, weil genau
#: diese Frage sich pruefen lassen muss -- ohne einen Server aufzusetzen.
gleiche_version() {
local laeuft="$1" liegt="$2"
[[ -n "$laeuft" && "$laeuft" == "$liegt" ]]
}
#: Merker fuer die EXIT-Falle. Zurueckgerollt wird nur, was auch gesichert ist.
SICHERUNG_STEHT=0
INSTALL_FERTIG=0
@@ -129,6 +163,7 @@ Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
--acme-email <a> Kontaktadresse fuer Let's Encrypt.
--skip-packages Keine Systempakete installieren
--yes Rueckfragen ueberspringen
--force Auch dann einrichten, wenn dieselbe Fassung schon laeuft
USAGE
}
@@ -144,6 +179,7 @@ while [[ $# -gt 0 ]]; do
--acme-email) ACME_EMAIL="${2:?}"; shift 2 ;;
--skip-packages) SKIP_PACKAGES=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
--force) ERZWINGEN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) fail "unbekannte Option: $1" ;;
esac
@@ -261,6 +297,23 @@ FAILED_DIR="/srv/$NAME-failed-$TIMESTAMP"
IS_UPDATE=0
[[ -f "$ROOT/data/app.db" ]] && IS_UPDATE=1
# Laeuft hier schon genau das, was hier liegt? Dann ist Schluss, bevor
# irgendetwas angefasst wird -- und zwar mit Ausstiegscode 0: es ist nichts
# schiefgegangen, es war nur nichts zu tun.
MODUL="tesm"
[[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
LIEGT="$(paket_version "$REPO_DIR/apps/$APP/pyproject.toml") $(
paket_version "$REPO_DIR/packages/tesm-core/pyproject.toml")"
LAEUFT="$(laufende_version "$ROOT" "$MODUL")"
if [[ $IS_UPDATE -eq 1 && $ERZWINGEN -eq 0 ]] && gleiche_version "$LAEUFT" "$LIEGT"; then
step "Nichts zu tun"
info "$APP ${LIEGT%% *} (tesm-core ${LIEGT##* }) laeuft hier bereits."
info "Ein zweiter Durchlauf wuerde nur die Umgebung neu bauen und den"
info "Dienst neu starten -- ohne dass sich etwas aendert."
info "Trotzdem neu einrichten: --force"
exit 0
fi
step "TESM-Installation"
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
info "Ziel : $ROOT"
@@ -278,6 +331,7 @@ else
info "nginx : Port $HTTP_PORT (nur HTTP)"
fi
info "Modus : $([[ $IS_UPDATE -eq 1 ]] && echo Aktualisierung || echo Neuinstallation)"
info "Fassung : ${LIEGT%% *} (tesm-core ${LIEGT##* })${LAEUFT:+, bisher ${LAEUFT%% *}}"
if [[ $ASSUME_YES -eq 0 ]]; then
read -r -p "Fortfahren? [j/N] " answer
+28
View File
@@ -33,6 +33,8 @@ EXTRA=("$@")
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
echo "Beispiel : update.sh tesm latest --instance test" >&2
echo "Ohne Rueckfrage: --yes anhaengen (ohne Terminal zwingend)." >&2
echo "Laeuft die Fassung schon, endet der Aufruf ohne Arbeit; --force setzt" >&2
echo "sie trotzdem neu auf." >&2
exit 2
}
@@ -173,6 +175,32 @@ release_holen() {
release_holen
# Schon installiert? Dann gar nicht erst fragen.
#
# Dieselbe Pruefung steht in install.sh und gilt dort endgueltig (sie
# vergleicht auch den Kern). Hier steht sie noch einmal, weil ein Update, das
# ohne Wirkung bleibt, gar nicht erst nach einer Bestaetigung fragen soll --
# und weil "es lief durch" sonst wie eine Aktualisierung aussieht.
ERZWINGEN=0
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
[[ "${EXTRA[i]}" == "--force" ]] && ERZWINGEN=1
done
INSTALLATION="$APP"
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
[[ "${EXTRA[i]}" == "--instance" ]] && INSTALLATION="$APP-${EXTRA[i + 1]:-}"
done
MODUL="tesm"
[[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
if [[ $ERZWINGEN -eq 0 && -x "/srv/$INSTALLATION/venv/bin/python" ]]; then
LAEUFT="$("/srv/$INSTALLATION/venv/bin/python" -c \
"import $MODUL; print($MODUL.__version__)" 2>/dev/null || true)"
if [[ -n "$LAEUFT" && "v$LAEUFT" == "$TAG" ]]; then
echo "==> $APP $LAEUFT laeuft hier bereits -- nichts zu tun."
echo " Trotzdem neu einrichten: --force"
exit 0
fi
fi
# --yes darf auch hier stehen, nicht nur bei install.sh und upgrade.sh. Es
# bleibt in EXTRA: install.sh bekommt es ohnehin, doppelt schadet nicht.
OHNE_RUECKFRAGE=0
+8
View File
@@ -120,6 +120,14 @@ sudo bash /srv/tesm/deploy/update.sh tesm
sudo bash /srv/tesm-license/deploy/update.sh tesm-license
```
**Läuft die Fassung schon, passiert nichts.** `update.sh` vergleicht den
aufgelösten Tag mit dem, was installiert ist, und endet mit `0`, bevor es eine
Rückfrage stellt; `install.sh` prüft es noch einmal genauer (Anwendung **und**
Kern) und bricht vor dem ersten Eingriff ab. Ein zweiter Lauf auf dieselbe
Fassung baut sonst die Python-Umgebung neu, kopiert dieselben Dateien und
startet den Dienst neu -- ein paar Sekunden Ausfall ohne jede Wirkung. Wer das
trotzdem will (eine beschädigte Installation etwa), hängt `--force` an.
Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
eine **benannte Instanz** zu aktualisieren:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-core"
version = "2.8.5"
version = "2.8.9"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = [
@@ -11,6 +11,58 @@
import RFB from "../vendor/novnc/core/rfb.js";
// 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, entfaellt nur die Auskunft, nicht die Sitzung.
//
// Zwei davon holen nur Auskunft (_fail, _socketClose). Der dritte greift in
// die Wahl des Sicherheitsverfahrens ein -- aber erst, **nachdem** eines
// nachweislich nicht getragen hat.
//
// Die Vorgeschichte gehoert dazu, damit niemand den Schritt zurueckgeht: hier
// stand einmal eine Vorauswahl, die ohne hinterlegte Zugangsdaten ein
// Verfahren ohne Anmeldung erzwang, weil RealVNC RA2ne zuerst nennt. Das war
// geraten und falsch. Was jetzt hier steht, ist das Gegenteil einer
// Vermutung: der Server nennt seine Reihenfolge, die gilt -- und wenn seine
// erste Wahl die Verbindung abreissen laesst, ist das ein Befund, auf den man
// antworten darf. Gemessen am Geraet: RealVNC bietet 6 (RA2ne) an, waehlt man
// es, legt es auf; daneben liegt 1 (ohne Authentifizierung), und darueber
// laeuft auch der Viewer.
const istUnterstuetzt = RFB.prototype._isSupportedSecurityType;
//: Verfahren, die in dieser Sitzung schon abgerissen sind.
const gescheitert = new Set();
//: Hoechstens ein zweiter Anlauf. Jeder Versuch ist eine Verbindung, und ein
//: RealVNC Server zaehlt abgebrochene Versuche mit -- wer dreimal anklopft,
//: sperrt sich aus.
const HOECHSTENS = 2;
// 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
// bevor eine Seite etwas daran aendern koennte. (Genau daran ist der erste
// Versuch gescheitert -- die Meldung blieb leer.)
let letzterGrund = "";
const originalFail = RFB.prototype._fail;
RFB.prototype._fail = function (details) {
letzterGrund = String(details);
return originalFail.call(this, details);
};
// Und der Abschluss der Leitung selbst: der Schliesscode sagt, ob TESM
// abgewiesen hat (1008), ob es ein Serverfehler war (1011) -- oder ob die
// Verbindung nie zustande kam (1006).
let letzterCode = 0;
const originalSocketClose = RFB.prototype._socketClose;
RFB.prototype._socketClose = function (ereignis) {
letzterCode = (ereignis && ereignis.code) || 0;
if (ereignis && ereignis.reason) {
letzterGrund = letzterGrund || String(ereignis.reason);
}
return originalSocketClose.call(this, ereignis);
};
const wurzel = document.getElementById("screen");
if (wurzel) {
starten(wurzel);
@@ -35,14 +87,92 @@ function starten(wurzel) {
);
let rfb = null;
let versuche = 0;
let stand = false;
let letzterZugang = {};
function melden(text, art = "") {
zeile.textContent = text;
zeile.className = `screen__status${art ? " screen__status--" + art : ""}`;
}
function grund() {
const teile = [];
if (letzterGrund) {
teile.push(letzterGrund.replace(/^Failed (when|while) \w+: /, ""));
}
if (rfb && rfb._rfbAuthScheme > 0) {
teile.push(`gewähltes Verfahren: ${rfb._rfbAuthScheme}`);
}
if (gescheitert.size) {
teile.push(`erfolglos: ${[...gescheitert].join(", ")}`);
}
return teile.join(" · ");
}
// Hat das gewählte Verfahren die Verbindung abreissen lassen? Dann ist das
// ein Befund und keine Vermutung -- der naechste Anlauf laesst es aus.
//
// Nur solange nie ein Bild stand: bricht eine **laufende** Sitzung ab, liegt
// es nicht am Anmeldeverfahren, und ein stiller Neuaufbau wuerde die Ursache
// verdecken.
function naechsterVersuch() {
const verfahren = rfb && rfb._rfbAuthScheme > 0 ? rfb._rfbAuthScheme : 0;
if (!verfahren || versuche >= HOECHSTENS || gescheitert.has(verfahren)) {
return false;
}
gescheitert.add(verfahren);
melden(
`Verfahren ${verfahren} hat die Verbindung nicht getragen – nächster Anlauf ` +
"mit dem nächsten, das der Server anbietet …",
"warnung",
);
const zugang = letzterZugang;
rfb = null;
window.setTimeout(() => verbinden(zugang), 400);
return true;
}
// Steht die WebSocket-Strecke zu TESM überhaupt? Zwei sehr verschiedene
// Ursachen sehen von aussen gleich aus: der VNC-Server mag nicht, oder die
// Verbindung kommt nie zustande, weil ein Proxy zwischen Browser und TESM
// "Upgrade" nicht durchreicht. Diese Probe trennt sie.
function streckeTesten() {
return new Promise((fertig) => {
let beantwortet = false;
const pruefling = new WebSocket(`${schema}//${window.location.host}/ws/probe`);
const schliessen = (ergebnis) => {
if (beantwortet) return;
beantwortet = true;
try {
pruefling.close();
} catch (fehler) {
/* schon zu */
}
fertig(ergebnis);
};
pruefling.onmessage = () => schliessen(true);
pruefling.onerror = () => schliessen(false);
pruefling.onclose = () => schliessen(false);
window.setTimeout(() => schliessen(false), 4000);
});
}
function verbinden(zugang) {
melden("Verbindung wird aufgebaut …");
melden(versuche ? "Nächster Versuch …" : "Verbindung wird aufgebaut …");
letzterGrund = "";
letzterCode = 0;
letzterZugang = zugang;
versuche += 1;
// Was abgerissen ist, wird nicht noch einmal gewaehlt. Sonst faende noVNC
// dasselbe Verfahren wieder zuerst -- und der zweite Anlauf waere eine
// Wiederholung des ersten.
RFB.prototype._isSupportedSecurityType = function (typ) {
if (gescheitert.has(typ)) return false;
return istUnterstuetzt.call(this, typ);
};
rfb = new RFB(leinwand, adresse, {
credentials: zugang,
// Eine Bildschirmsitzung soll nicht nebenbei die Zwischenablage des
@@ -55,6 +185,7 @@ function starten(wurzel) {
rfb.focusOnClick = true;
rfb.addEventListener("connect", () => {
stand = true;
melden(`Verbunden mit ${wurzel.dataset.ziel}`, "ok");
abfrage.hidden = true;
leinwand.hidden = false;
@@ -68,12 +199,35 @@ function starten(wurzel) {
knopfStrg.hidden = true;
if (ereignis.detail && ereignis.detail.clean) {
melden("Sitzung beendet.");
} else if (!stand && naechsterVersuch()) {
return;
} else {
const erklaerung = grund();
melden(
"Verbindung abgebrochen. Antwortet der VNC-Server noch, und lässt der " +
"Arbeitsplatz die Verbindung zu?",
erklaerung
? `Verbindung abgebrochen – ${erklaerung}`
: "Verbindung abgebrochen. Wird geprüft, woran es liegt …",
"fehler",
);
// 1006 heisst: die Leitung ging zu, ohne dass die Gegenseite einen
// Grund geschickt hat -- typisch, wenn die WebSocket-Verbindung gar
// nicht erst zustande kam. Dann sagt die Probe, wer schweigt.
if (letzterCode === 1006 || !erklaerung) {
streckeTesten().then((steht) => {
melden(
steht
? `${erklaerung || "Verbindung abgebrochen"} – die WebSocket-Strecke zu ` +
"TESM selbst steht (Probe erfolgreich), der Abbruch kommt also von " +
"der VNC-Seite."
: "Die WebSocket-Verbindung zu TESM kommt nicht zustande – auch eine " +
"einfache Probe scheitert. Ein Proxy zwischen Browser und TESM muss " +
"die Kopfzeilen Upgrade und Connection durchreichen (nginx: " +
"location /ws/ mit proxy_set_header Upgrade $http_upgrade). Dasselbe " +
"gilt für die Browser-Konsole.",
"fehler",
);
});
}
}
rfb = null;
});
@@ -20,6 +20,27 @@ der einen Bildschirm fernsteuert.
Datei -- mit Grund und Fundstelle, damit ein Wechsel auf eine neuere Fassung
nicht stillschweigend darueberlaeuft.
**Was TESM von aussen umbiegt** (also *nicht* in diesen Dateien steht, sondern
in `js/screen.js`):
* `RFB.prototype._fail` und `_socketClose` -- nur, um den Grund eines
Abbruchs zu erfahren. noVNC meldet ihn sonst ausschliesslich in die
Entwicklerkonsole, und `console.error` mitzulesen traegt nicht: die
Bibliothek bindet es beim Laden.
* `RFB.prototype._isSupportedSecurityType` -- **erst nachdem** ein Verfahren
die Verbindung nachweislich hat abreissen lassen. Dann wird es fuer den
zweiten Anlauf ausgelassen, damit noVNC nicht wieder dasselbe zuerst findet.
Hoechstens zwei Anlaeufe, denn jeder ist eine Verbindung, und ein RealVNC
Server zaehlt abgebrochene mit.
Hier stand einmal das Gegenteil: eine **Vorauswahl**, die ohne hinterlegte
Zugangsdaten ein Verfahren ohne Anmeldung erzwang. Das war geraten und hat
eine laufende Sitzung gebrochen. Der Unterschied ist nicht der Code, sondern
der Anlass: eine Vermutung vorher gegen einen Befund hinterher.
Faellt eine dieser Methoden in einer neueren Fassung weg, entfaellt nur die
Auskunft bzw. der zweite Anlauf -- die Sitzung laeuft wie ohne sie.
**Was noVNC an Sicherheitsverfahren spricht** (`core/rfb.js`,
`_isSupportedSecurityType`): `1` None, `2` VNC-Passwort, `6` RA2ne (RSA-AES
ohne Streckenverschluesselung), `16` Tight, `19` VeNCrypt, `22` xvp, `30` ARD,