Compare commits

...
4 Commits
14 changed files with 398 additions and 156 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.16" version = "2.8.20"
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 = [
+15 -21
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,15 +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, 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
@bp.get("/<int:device_id>/vnc") @bp.get("/<int:device_id>/vnc")
@@ -419,12 +411,14 @@ def vnc_connection(device_id: int):
ziel = vnc.ziel(device) ziel = vnc.ziel(device)
if not ziel: if not ziel:
abort(404) abort(404)
with transaction(): # Kein Eintrag in der Aenderungskette: eine Verbindungsdatei ist derselbe
_audit( # Vorgang wie die Sitzung im Browser, nur fuer einen Viewer auf dem
conn, # Arbeitsplatz -- und sie aendert nichts. Wer zusieht, steht im laufenden
"device.vnc_opened", # Protokoll; das Aenderungsprotokoll bleibt die Liste der Eingriffe.
str(device["name"]), live = core().registry.get("live_log")
f"Verbindungsdatei für {ziel}:{port}", if live is not None:
live.info(
"vnc", f"{current_user.username} lädt die Verbindungsdatei für {device['name']}"
) )
return Response( return Response(
vnc.verbindungsdatei( vnc.verbindungsdatei(
+123 -40
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
@@ -50,6 +50,10 @@ VERBINDUNGSLIMIT = 6.0
#: bis ein Schub weitergereicht wird. #: bis ein Schub weitergereicht wird.
BLOCK = 32 * 1024 BLOCK = 32 * 1024
#: Wie oft nachgesehen wird, ob die Sitzung noch etwas tut. Kurz genug, dass
#: das Trennen puenktlich ist, lang genug, dass das Nachsehen nichts kostet.
TAKT = 1.0
def _geraet(conn: Any, device_id: int) -> dict[str, Any] | None: def _geraet(conn: Any, device_id: int) -> dict[str, Any] | None:
device = inventory.get_device(conn, device_id) device = inventory.get_device(conn, device_id)
@@ -71,11 +75,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,17 +91,7 @@ def screen(device_id: int):
if not port: if not port:
abort(404) abort(404)
zugang, frisch = vnc.zugang_gemerkt( zugang = vnc.zugang_gemerkt(device, port=port)
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 = "", "" 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:
@@ -132,6 +128,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)
@@ -202,37 +235,91 @@ def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def]
except OSError as exc: except OSError as exc:
ws.close(1011, f"{host}:{port} antwortet nicht ({exc}).") ws.close(1011, f"{host}:{port} antwortet nicht ({exc}).")
return return
# Das Zeitlimit galt dem *Aufbau* -- als Abbruchgrund taugt es nicht.
#
# ``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.
#
# Stattdessen ein kurzer Takt: das Lesen laeuft immer wieder in ein
# Zeitlimit, und *das* ist kein Abbruch, sondern der Anlass nachzusehen,
# ob die Sitzung ueberhaupt noch etwas tut.
gegenstelle.settimeout(TAKT)
leerlauf = int(extension.settings.get(conn, "vnc_idle_timeout") or 0)
# 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() begonnen = time.monotonic()
with extension.database.transaction(conn): live = extension.registry.get("live_log")
audit.log( if live is not None:
conn, live.info("vnc", f"{current_user.username} öffnet den Bildschirm von {device['name']}")
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",
)
laeuft = threading.Event() laeuft = threading.Event()
laeuft.set() laeuft.set()
#: Wann zuletzt etwas floss -- in *irgendeine* Richtung. Eine Liste, weil
#: beide Richtungen daran schreiben und eine Zuweisung in Python nicht in
#: die andere hineinlaufen kann.
zuletzt = [time.monotonic()]
#: Grund des Endes, falls wir selbst getrennt haben.
schluss = [""]
#: Kam ueberhaupt schon ein Byte vom Geraet?
gehoert = [False]
def vom_geraet() -> None: def vom_geraet() -> None:
"""Alles, was der VNC-Server schickt, geht als Binaerrahmen weiter.""" """Alles, was der VNC-Server schickt, geht als Binaerrahmen weiter."""
try: try:
while laeuft.is_set(): while laeuft.is_set():
daten = gegenstelle.recv(BLOCK) try:
daten = gegenstelle.recv(BLOCK)
except socket.timeout:
# Kein Fehler, nur Stille -- und die bedeutet zweierlei,
# je nachdem, ob die Sitzung schon stand.
#
# **Vor dem ersten Byte** ist Stille ein Befund: der Port
# nimmt an, aber es kommt nichts. Genau so verhaelt sich
# ein RealVNC Server, der die Adresse gerade aussperrt. Wer
# das nicht erfaehrt, sieht ewig "Verbindung wird
# aufgebaut" -- und sucht den Fehler bei uns.
if not gehoert[0] and time.monotonic() - begonnen > VERBINDUNGSLIMIT:
schluss[0] = (
f"{host}:{port} nimmt die Verbindung an, schickt aber nichts. "
"So verhält sich ein VNC-Server, der die Adresse gerade "
"aussperrt (RealVNC: BlacklistTimeout) – ein Neustart des "
"VNC-Dienstes auf dem Gerät räumt das ab."
)
break
# **Danach** ist Stille der Normalfall: ein Bildschirm, auf
# dem sich nichts tut, schickt nichts.
if gehoert[0] and leerlauf and time.monotonic() - zuletzt[0] > leerlauf:
schluss[0] = (
f"Sitzung nach {leerlauf} Sekunden ohne Bewegung getrennt"
)
break
continue
if not daten: if not daten:
if not gehoert[0]:
schluss[0] = (
f"{host}:{port} hat die Verbindung sofort wieder geschlossen."
)
break break
gehoert[0] = True
zuletzt[0] = time.monotonic()
ws.send(daten) ws.send(daten)
except Exception: # noqa: BLE001 - Abbruch ist der Normalfall, nicht die Stoerung except Exception: # noqa: BLE001 - Abbruch ist der Normalfall, nicht die Stoerung
pass pass
finally: finally:
laeuft.clear() laeuft.clear()
try: try:
ws.close() if schluss[0]:
ws.close(1000 if gehoert[0] else 1011, schluss[0])
else:
ws.close()
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
pass pass
@@ -249,6 +336,7 @@ def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def]
# waere entweder ein Irrtum oder ein Versuch -- beides endet # waere entweder ein Irrtum oder ein Versuch -- beides endet
# hier und nicht auf dem Geraet. # hier und nicht auf dem Geraet.
continue continue
zuletzt[0] = time.monotonic()
gegenstelle.sendall(nachricht) gegenstelle.sendall(nachricht)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
logger.debug("Bildschirmsitzung beendet (%s)", device["name"], exc_info=True) logger.debug("Bildschirmsitzung beendet (%s)", device["name"], exc_info=True)
@@ -260,14 +348,9 @@ def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def]
pass pass
gegenstelle.close() gegenstelle.close()
dauer = int(time.monotonic() - begonnen) dauer = int(time.monotonic() - begonnen)
with extension.database.session() as frisch, extension.database.transaction(frisch): if live is not None:
audit.log( live.info(
frisch, "vnc",
actor=current_user.username, f"Bildschirmsitzung {device['name']} beendet ({dauer}s)"
actor_id=current_user.id, + (f" – {schluss[0]}" if schluss[0] else ""),
action="device.screen_closed",
target=str(device["name"]),
detail=f"Dauer {dauer}s",
ip=client_ip(),
severity="info",
) )
@@ -105,6 +105,7 @@ def system():
"dashboard_public", "dashboard_public",
"vnc_enabled", "vnc_enabled",
"vnc_port", "vnc_port",
"vnc_idle_timeout",
"vnc_warn_unencrypted_off", "vnc_warn_unencrypted_off",
"terminal_switches_enabled", "terminal_switches_enabled",
"terminal_clients_enabled", "terminal_clients_enabled",
@@ -148,6 +149,7 @@ def system_save():
"dashboard_public", "dashboard_public",
"vnc_enabled", "vnc_enabled",
"vnc_port", "vnc_port",
"vnc_idle_timeout",
"vnc_warn_unencrypted_off", "vnc_warn_unencrypted_off",
"terminal_switches_enabled", "terminal_switches_enabled",
"terminal_clients_enabled", "terminal_clients_enabled",
+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. 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.
#: 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
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( Jetzt beruehrt das Oeffnen einer Seite das Geraet gar nicht mehr. Die
device: Mapping[str, Any], *, port: int = STANDARD_PORT, pruefen: bool = True Erreichbarkeit kommt aus dem Ueberwachungsdurchlauf (ein blosser
) -> tuple[Zugang, bool]: Verbindungsaufbau), die Anmeldeverfahren aus der letzten **ausdruecklichen**
"""Der Zugang -- aus der letzten Messung, wenn die frisch genug ist. Pruefung -- der Knopf dafuer steht auf der Seite und wird gedrueckt, wenn
etwas nicht geht.
Gibt ``(Zugang, frisch_gemessen)`` zurueck; das zweite sagt dem Aufrufer,
ob er das Ergebnis festhalten muss.
""" """
host = ziel(device) gespeichert = str(device.get("vnc_types") or "")
if not pruefen: return Zugang(
return Zugang(ziel=host, port=port, erreichbar=False), False ziel=ziel(device),
port=port,
gemessen_am = str(device.get("vnc_types_at") or "") erreichbar=bool(device.get("vnc_open")),
if gemessen_am and _juenger_als(gemessen_am, MESSUNG_GILT): verfahren=tuple(
gespeichert = str(device.get("vnc_types") or "")
verfahren = tuple(
int(teil) for teil in gespeichert.split(",") if teil.strip().isdigit() int(teil) for teil in gespeichert.split(",") if teil.strip().isdigit()
) ),
return ( absage=str(device.get("vnc_reason") or ""),
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( def verbindungsdatei(
+12
View File
@@ -66,6 +66,18 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
minimum=1, minimum=1,
maximum=65535, maximum=65535,
), ),
SettingSpec(
"vnc_idle_timeout",
300,
"int",
"Bildschirmsitzung bei Untätigkeit trennen nach (Sekunden)",
hint="Gezählt wird, wenn in **keine** Richtung mehr etwas fließt: der Bildschirm "
"ändert sich nicht und es kommt keine Eingabe. Eine Sitzung, die jemand offen "
"vergisst, hält sonst die Verbindung zum Gerät. 0 schaltet die Trennung ab.",
section="monitoring",
minimum=0,
maximum=86400,
),
SettingSpec( SettingSpec(
"vnc_warn_unencrypted_off", "vnc_warn_unencrypted_off",
False, False,
+15 -1
View File
@@ -40,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>
@@ -49,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
@@ -46,17 +46,6 @@
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar', {{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
checked=values.dashboard_public, checked=values.dashboard_public,
hint_text=specs['dashboard_public'].hint) }} hint_text=specs['dashboard_public'].hint) }}
{{ checkbox('vnc_enabled', 'Bildschirmzugriff (VNC) anbieten',
checked=values.vnc_enabled,
hint_text=specs['vnc_enabled'].hint) }}
<div class="form-grid">
{{ 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>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div> <div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form> </form>
@@ -117,6 +106,39 @@
</form> </form>
</section> </section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('monitor', size=17) }} Bildschirm (VNC)</div>
<div class="card__sub">
Dieselben Regeln wie bei der Konsole: eigenes Recht, frische
Passwortbestätigung, getrennte Zwischenablage. Der Knopf erscheint nur, wo
ein VNC-Server antwortet – das prüft der Überwachungsdurchlauf mit.
</div>
</div>
</div>
<form method="post" action="{{ url_for('settings.system_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{{ checkbox('vnc_enabled', 'Bildschirmzugriff (VNC) anbieten',
checked=values.vnc_enabled,
hint_text=specs['vnc_enabled'].hint) }}
<div class="form-grid">
{{ field('vnc_port', 'VNC-Port', value=values.vnc_port, type='number',
hint_text=specs['vnc_port'].hint) }}
{{ field('vnc_idle_timeout', 'Trennen bei Untätigkeit (Sekunden)',
value=values.vnc_idle_timeout, type='number',
hint_text=specs['vnc_idle_timeout'].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>
</section>
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
+54
View File
@@ -39,6 +39,7 @@ HTTPS=0
HTTPS_PORT="" HTTPS_PORT=""
DOMAIN="" DOMAIN=""
ACME_EMAIL="" ACME_EMAIL=""
ERZWINGEN=0
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && 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; } fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
info() { printf ' %s\n' "$*"; } 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. #: Merker fuer die EXIT-Falle. Zurueckgerollt wird nur, was auch gesichert ist.
SICHERUNG_STEHT=0 SICHERUNG_STEHT=0
INSTALL_FERTIG=0 INSTALL_FERTIG=0
@@ -129,6 +163,7 @@ Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
--acme-email <a> Kontaktadresse fuer Let's Encrypt. --acme-email <a> Kontaktadresse fuer Let's Encrypt.
--skip-packages Keine Systempakete installieren --skip-packages Keine Systempakete installieren
--yes Rueckfragen ueberspringen --yes Rueckfragen ueberspringen
--force Auch dann einrichten, wenn dieselbe Fassung schon laeuft
USAGE USAGE
} }
@@ -144,6 +179,7 @@ while [[ $# -gt 0 ]]; do
--acme-email) ACME_EMAIL="${2:?}"; shift 2 ;; --acme-email) ACME_EMAIL="${2:?}"; shift 2 ;;
--skip-packages) SKIP_PACKAGES=1; shift ;; --skip-packages) SKIP_PACKAGES=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;; --yes|-y) ASSUME_YES=1; shift ;;
--force) ERZWINGEN=1; shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) fail "unbekannte Option: $1" ;; *) fail "unbekannte Option: $1" ;;
esac esac
@@ -261,6 +297,23 @@ FAILED_DIR="/srv/$NAME-failed-$TIMESTAMP"
IS_UPDATE=0 IS_UPDATE=0
[[ -f "$ROOT/data/app.db" ]] && IS_UPDATE=1 [[ -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" step "TESM-Installation"
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}" info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
info "Ziel : $ROOT" info "Ziel : $ROOT"
@@ -278,6 +331,7 @@ else
info "nginx : Port $HTTP_PORT (nur HTTP)" info "nginx : Port $HTTP_PORT (nur HTTP)"
fi fi
info "Modus : $([[ $IS_UPDATE -eq 1 ]] && echo Aktualisierung || echo Neuinstallation)" 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 if [[ $ASSUME_YES -eq 0 ]]; then
read -r -p "Fortfahren? [j/N] " answer 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 "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
echo "Beispiel : update.sh tesm latest --instance test" >&2 echo "Beispiel : update.sh tesm latest --instance test" >&2
echo "Ohne Rueckfrage: --yes anhaengen (ohne Terminal zwingend)." >&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 exit 2
} }
@@ -173,6 +175,32 @@ release_holen() {
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 # --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. # bleibt in EXTRA: install.sh bekommt es ohnehin, doppelt schadet nicht.
OHNE_RUECKFRAGE=0 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 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, Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
eine **benannte Instanz** zu aktualisieren: eine **benannte Instanz** zu aktualisieren:
+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.10"
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,33 @@
import RFB from "../vendor/novnc/core/rfb.js"; 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.
//
// 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 // Drei Eingriffe in private Methoden der mitgelieferten Bibliothek. Bewusst
// hier und nicht in der Bibliothek selbst: eine neue Fassung von noVNC laesst // hier und nicht in der Bibliothek selbst: eine neue Fassung von noVNC laesst
// sich dann austauschen, ohne eine Aenderung nachzupflegen -- faellt eine // sich dann austauschen, ohne eine Aenderung nachzupflegen -- faellt eine
// Methode weg, greift der jeweilige Eingriff nicht mehr, und die Sitzung // Methode weg, entfaellt nur die Auskunft, nicht die Sitzung.
// laeuft wieder wie ohne ihn. Kein stiller Bruch. //
// 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; 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 // 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
@@ -79,6 +87,9 @@ function starten(wurzel) {
); );
let rfb = null; let rfb = null;
let versuche = 0;
let stand = false;
let letzterZugang = {};
function melden(text, art = "") { function melden(text, art = "") {
zeile.textContent = text; zeile.textContent = text;
@@ -93,9 +104,35 @@ function starten(wurzel) {
if (rfb && rfb._rfbAuthScheme > 0) { if (rfb && rfb._rfbAuthScheme > 0) {
teile.push(`gewähltes Verfahren: ${rfb._rfbAuthScheme}`); teile.push(`gewähltes Verfahren: ${rfb._rfbAuthScheme}`);
} }
if (gescheitert.size) {
teile.push(`erfolglos: ${[...gescheitert].join(", ")}`);
}
return teile.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 // Steht die WebSocket-Strecke zu TESM überhaupt? Zwei sehr verschiedene
// Ursachen sehen von aussen gleich aus: der VNC-Server mag nicht, oder die // Ursachen sehen von aussen gleich aus: der VNC-Server mag nicht, oder die
// Verbindung kommt nie zustande, weil ein Proxy zwischen Browser und TESM // Verbindung kommt nie zustande, weil ein Proxy zwischen Browser und TESM
@@ -122,22 +159,17 @@ function starten(wurzel) {
} }
function verbinden(zugang) { function verbinden(zugang) {
melden("Verbindung wird aufgebaut …"); melden(versuche ? "Nächster Versuch …" : "Verbindung wird aufgebaut …");
letzterGrund = ""; letzterGrund = "";
letzterCode = 0; letzterCode = 0;
letzterZugang = zugang;
versuche += 1;
// Vorauswahl nur, wenn wir nichts anzubieten haben: dann zaehlt der Weg, // Was abgerissen ist, wird nicht noch einmal gewaehlt. Sonst faende noVNC
// der ohne Zugangsdaten auskommt -- sofern der Server ihn anbietet. // dasselbe Verfahren wieder zuerst -- und der zweite Anlauf waere eine
const hatZugangsdaten = Boolean(zugang && (zugang.password || zugang.username)); // Wiederholung des ersten.
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) { RFB.prototype._isSupportedSecurityType = function (typ) {
if (!hatZugangsdaten && ohnePasswortMoeglich) { if (gescheitert.has(typ)) return false;
return OHNE_ZUGANGSDATEN.has(typ);
}
return istUnterstuetzt.call(this, typ); return istUnterstuetzt.call(this, typ);
}; };
@@ -153,6 +185,7 @@ function starten(wurzel) {
rfb.focusOnClick = true; rfb.focusOnClick = true;
rfb.addEventListener("connect", () => { rfb.addEventListener("connect", () => {
stand = true;
melden(`Verbunden mit ${wurzel.dataset.ziel}`, "ok"); melden(`Verbunden mit ${wurzel.dataset.ziel}`, "ok");
abfrage.hidden = true; abfrage.hidden = true;
leinwand.hidden = false; leinwand.hidden = false;
@@ -165,7 +198,12 @@ function starten(wurzel) {
knopfTrennen.hidden = true; knopfTrennen.hidden = true;
knopfStrg.hidden = true; knopfStrg.hidden = true;
if (ereignis.detail && ereignis.detail.clean) { if (ereignis.detail && ereignis.detail.clean) {
melden("Sitzung beendet."); // Beim sauberen Schluss steht der Grund im Schliessrahmen -- etwa,
// dass TESM wegen Untätigkeit getrennt hat. Ohne ihn stuende da
// "beendet", und niemand wuesste, warum das Bild weg ist.
melden(letzterGrund ? `Sitzung beendet – ${letzterGrund}` : "Sitzung beendet.");
} else if (!stand && naechsterVersuch()) {
return;
} else { } else {
const erklaerung = grund(); const erklaerung = grund();
melden( melden(
@@ -21,14 +21,25 @@ Datei -- mit Grund und Fundstelle, damit ein Wechsel auf eine neuere Fassung
nicht stillschweigend darueberlaeuft. nicht stillschweigend darueberlaeuft.
**Was TESM von aussen umbiegt** (also *nicht* in diesen Dateien steht, sondern **Was TESM von aussen umbiegt** (also *nicht* in diesen Dateien steht, sondern
in `js/screen.js`): `RFB.prototype._isSupportedSecurityType` wird ueberschrieben, in `js/screen.js`):
solange keine Zugangsdaten vorliegen und der Server ein Verfahren ohne
Anmeldung anbietet. Grund: noVNC nimmt "das erste, das ich kann, in der * `RFB.prototype._fail` und `_socketClose` -- nur, um den Grund eines
Reihenfolge des Servers"; ein RealVNC Server nennt RA2ne vor "ohne Abbruchs zu erfahren. noVNC meldet ihn sonst ausschliesslich in die
Authentifizierung", und der Browser liefe in eine Passwortabfrage, obwohl Entwicklerkonsole, und `console.error` mitzulesen traegt nicht: die
daneben der Weg offensteht, den auch der RealVNC Viewer nimmt. Faellt die Bibliothek bindet es beim Laden.
Methode in einer neueren Fassung weg, greift die Vorauswahl nicht mehr -- die * `RFB.prototype._isSupportedSecurityType` -- **erst nachdem** ein Verfahren
Sitzung laeuft dann wie ohne sie, es bricht nichts. 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`, **Was noVNC an Sicherheitsverfahren spricht** (`core/rfb.js`,
`_isSupportedSecurityType`): `1` None, `2` VNC-Passwort, `6` RA2ne (RSA-AES `_isSupportedSecurityType`): `1` None, `2` VNC-Passwort, `6` RA2ne (RSA-AES