diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index 7f8aebf..88b8615 100644 --- a/apps/tesm/pyproject.toml +++ b/apps/tesm/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm" -version = "2.8.21" +version = "2.8.22" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm/src/tesm/blueprints/dashboard.py b/apps/tesm/src/tesm/blueprints/dashboard.py index 045fc8e..0af03e9 100644 --- a/apps/tesm/src/tesm/blueprints/dashboard.py +++ b/apps/tesm/src/tesm/blueprints/dashboard.py @@ -14,7 +14,7 @@ from __future__ import annotations from typing import Any -from flask import Blueprint, abort, render_template, request +from flask import Blueprint, abort, render_template, request, url_for from tesm_core.security.sessions import fragment from tesm_core.extension import core, db @@ -49,6 +49,13 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]: # Einmal je Seitenaufbau, nicht je Kachel: der Port ist fuer alle derselbe, # und ohne das Recht wird gar nicht erst nachgesehen. vnc_erlaubt = detailed and current_user.has("device_vnc.execute") + # Das Fenster setzt eine frische Passwortbestaetigung voraus -- dieselbe + # Schwelle wie die Geraeteseite. Fehlt sie, fuehrt der Knopf dorthin: dort + # wird danach gefragt, und ein Fenster, das sich mit "bitte erst + # bestaetigen" oeffnet, waere ein Klick fuer nichts. + from tesm_core.auth.service import reauth_is_fresh + + vnc_fenster = vnc_erlaubt and reauth_is_fresh() vnc_port = ( int(core().settings.get(conn, "vnc_port") or vnc_dienst.STANDARD_PORT) if vnc_erlaubt and core().settings.get(conn, "vnc_enabled") @@ -95,9 +102,16 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]: # Verbindungsversuch je Kachel und Aufbau waere genau die # Art Hintergrundlast, die niemand bestellt hat. "vnc": bool(vnc_port and row["vnc_open"]), + "vnc_quelle": ( + url_for("screen.session_json", device_id=int(row["id"])) + if vnc_port and row["vnc_open"] + else "" + ), } ) tiles.append(entry) + # Merker fuer die Vorlage: nur mit frischer Bestaetigung gibt es das + # Fenster; sonst zeigt die Kachel auf die Seite. sections = [ { @@ -142,6 +156,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]: "counts": counts, "sections": sections, "detailed": detailed, + "vnc_fenster": vnc_fenster, "interval": int(core().settings.get(conn, "monitor_interval_seconds") or 300), } diff --git a/apps/tesm/src/tesm/blueprints/screen.py b/apps/tesm/src/tesm/blueprints/screen.py index c3e6d4f..ffed11b 100644 --- a/apps/tesm/src/tesm/blueprints/screen.py +++ b/apps/tesm/src/tesm/blueprints/screen.py @@ -30,7 +30,15 @@ import threading import time from typing import Any -from flask import Blueprint, abort, flash, redirect, render_template, url_for +from flask import ( + Blueprint, + abort, + flash, + redirect, + render_template, + request, + 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 @@ -92,42 +100,51 @@ def screen(device_id: int): abort(404) 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: - try: - benutzer, passwort = inventory.credential_secret( - conn, core().keystore, int(device["credential_id"]) - ) - except inventory.ValidationError: - benutzer, passwort = "", "" - if passwort: - with core().database.transaction(conn): - audit.log( - conn, - actor=current_user.username, - actor_id=current_user.id, - action="device.vnc_credentials_used", - target=str(device["name"]), - detail=( - f"Hinterlegte Zugangsdaten ({benutzer}) an die Bildschirmsitzung " - "übergeben" - ), - ip=client_ip(), - severity="warning", - ) - return render_template( "tesm/screen.html", device=device, zugang=zugang, - websocket_path=f"/ws/screen/{device_id}", - vorgaben={"username": benutzer, "password": passwort}, - darf_geheimnis=darf_geheimnis, + quelle=url_for("screen.session_json", device_id=device_id), + darf_geheimnis=bool(current_user.has("credentials.secrets")), verfahren_name=vnc.verfahren_name, ) +def _zugangsdaten(conn: Any, device: dict[str, Any]) -> tuple[str, str]: + """Hinterlegte Zugangsdaten -- nur fuer den, der Geheimnisse sehen darf. + + Weil der Browser die Anmeldung fuehrt, muessen sie in die Seite. Wer + Geheimnisse nicht sehen darf, bekommt sie auch hier nicht: sonst waere die + Bildschirmsitzung der Umweg, auf dem jeder das Passwort liest. Jede + Herausgabe steht im Aenderungsprotokoll -- das ist kein Zusehen, sondern + ein Zugriff auf etwas, das sonst niemand sieht. + """ + if not device.get("credential_id") or not current_user.has("credentials.secrets"): + return "", "" + try: + benutzer, passwort = inventory.credential_secret( + conn, core().keystore, int(device["credential_id"]) + ) + except inventory.ValidationError: + return "", "" + if passwort: + with core().database.transaction(conn): + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action="device.vnc_credentials_used", + target=str(device["name"]), + detail=( + f"Hinterlegte Zugangsdaten ({benutzer}) an die Bildschirmsitzung " + "übergeben" + ), + ip=client_ip(), + severity="warning", + ) + return benutzer, passwort + + @bp.post("/monitoring/clients//screen/probe") @permission_required("device_vnc.execute") @reauth_required @@ -165,6 +182,73 @@ def probe(device_id: int): return redirect(url_for("screen.screen", device_id=device_id)) +@bp.get("/monitoring/clients//screen/session") +@permission_required("device_vnc.execute") +def session_json(device_id: int): + """Alles, was der Client fuer eine Sitzung braucht -- als Auskunft. + + Warum nicht in die Seite geschrieben: dieselbe Sitzung laeuft auch im + Fenster der Statusuebersicht, und dort stuenden sonst die Zugangsdaten + **jedes** Geraets im Quelltext der Seite. So holt sie sich nur, wer eine + Sitzung wirklich oeffnet -- und nur fuer das eine Geraet. + + Die frische Passwortbestaetigung wird hier von Hand geprueft statt ueber + den Dekorator: der wuerde auf eine Seite umleiten, und eine Umleitung ist + fuer einen Abruf im Hintergrund keine Auskunft. 409 mit einem Hinweis ist + eine. + """ + conn = db() + device = _geraet(conn, device_id) + if device is None: + abort(404) + port = _port(conn) + if not port: + abort(404) + if not reauth_is_fresh(): + return {"reauth": True}, 409 + + zugang = vnc.zugang_gemerkt(device, port=port) + benutzer, passwort = _zugangsdaten(conn, device) + return { + "name": str(device["name"]), + "socket": f"/ws/screen/{device_id}", + "ziel": f"{zugang.ziel}:{zugang.port}", + "verfahren": list(zugang.verfahren), + "bewaehrt": zugang.bewaehrt, + "hinderungsgrund": zugang.hinderungsgrund, + "meldeweg": url_for("screen.merke_verfahren", device_id=device_id), + "vorgaben": {"username": benutzer, "password": passwort}, + } + + +@bp.post("/monitoring/clients//screen/method") +@permission_required("device_vnc.execute") +def merke_verfahren(device_id: int): + """Der Browser meldet, womit die Sitzung zustande kam. + + Nur er weiss es: die Strecke reicht Bytes durch und liest das Protokoll + nicht. Beim naechsten Mal faengt die Seite damit an, statt wieder bei der + ersten Wahl des Servers -- die hier schon einmal abgerissen ist und deren + Absage sich ein sperrender RealVNC Server Minuten Zeit laesst. + """ + conn = db() + device = _geraet(conn, device_id) + if device is None: + abort(404) + try: + verfahren = int(request.json.get("verfahren", 0)) if request.json else 0 + except (TypeError, ValueError): + verfahren = 0 + if not 0 < verfahren < 256: + return {"ok": False}, 400 + + from ..services import monitor + + with core().database.transaction(conn): + monitor.merke_verfahren_das_traegt(conn, int(device["id"]), verfahren) + return {"ok": True} + + def init_app(app) -> None: # type: ignore[no-untyped-def] """Meldet Seite und -- falls verfuegbar -- die WebSocket-Strecke an.""" app.register_blueprint(bp) @@ -246,6 +330,10 @@ def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def] # 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. + # Ohne das sammelt der Kern kleine Pakete, bis genug beisammen ist + # (Nagle) -- bei einem Bildschirm sind Tastendruck und Mausbewegung genau + # solche kleinen Pakete, und das Warten darauf faellt als Ruckeln auf. + gegenstelle.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) gegenstelle.settimeout(TAKT) leerlauf = int(extension.settings.get(conn, "vnc_idle_timeout") or 0) diff --git a/apps/tesm/src/tesm/schema.py b/apps/tesm/src/tesm/schema.py index a8bacbc..313fafd 100644 --- a/apps/tesm/src/tesm/schema.py +++ b/apps/tesm/src/tesm/schema.py @@ -539,6 +539,19 @@ ALTER TABLE device_status ADD COLUMN vnc_types_at TEXT; """ +# Version 14 ---------------------------------------------------------------- +# +# Welches Anmeldeverfahren zuletzt wirklich getragen hat. Der Server nennt +# seine Reihenfolge, und die gilt -- aber wenn seine erste Wahl schon einmal +# abgerissen ist und eine andere zum Bild gefuehrt hat, ist das ein Befund. +# Ihn zu vergessen kostet bei jedem Oeffnen einen Fehlversuch, und RealVNC +# laesst sich mit der Absage Zeit: gemessen zwei bis drei Minuten, bis das +# Bild stand. +V14 = """ +ALTER TABLE device_status ADD COLUMN vnc_method INTEGER NOT NULL DEFAULT 0; +""" + + def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def] """Aus sieben Standardgruppen werden drei -- ohne jemandem etwas wegzunehmen. @@ -585,4 +598,5 @@ TESM_MIGRATIONS: tuple[Migration, ...] = ( Migration(version=11, name="vnc-ziel", sql=V11), Migration(version=12, name="vnc-verfahren", sql=V12), Migration(version=13, name="vnc-absage", sql=V13), + Migration(version=14, name="vnc-verfahren-das-traegt", sql=V14), ) diff --git a/apps/tesm/src/tesm/services/inventory.py b/apps/tesm/src/tesm/services/inventory.py index 70e1f63..da5686d 100644 --- a/apps/tesm/src/tesm/services/inventory.py +++ b/apps/tesm/src/tesm/services/inventory.py @@ -545,7 +545,8 @@ def list_devices( 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_reason, '') AS vnc_reason, st.vnc_types_at + COALESCE(st.vnc_reason, '') AS vnc_reason, st.vnc_types_at, + COALESCE(st.vnc_method, 0) AS vnc_method FROM devices d LEFT JOIN switches s ON s.id = d.switch_id AND s.deleted_at IS NULL LEFT JOIN credentials c ON c.id = d.credential_id AND c.deleted_at IS NULL diff --git a/apps/tesm/src/tesm/services/monitor.py b/apps/tesm/src/tesm/services/monitor.py index 36d6490..493900c 100644 --- a/apps/tesm/src/tesm/services/monitor.py +++ b/apps/tesm/src/tesm/services/monitor.py @@ -540,6 +540,15 @@ def merke_verfahren( ) +def merke_verfahren_das_traegt( + conn: sqlite3.Connection, device_id: int, verfahren: int +) -> None: + """Haelt fest, womit die letzte Sitzung wirklich zustande kam.""" + conn.execute( + "UPDATE device_status SET vnc_method=? WHERE device_id=?", (int(verfahren), device_id) + ) + + def run_sweep( *, database: Any, diff --git a/apps/tesm/src/tesm/services/vnc.py b/apps/tesm/src/tesm/services/vnc.py index f644fc7..7d1b5f5 100644 --- a/apps/tesm/src/tesm/services/vnc.py +++ b/apps/tesm/src/tesm/services/vnc.py @@ -64,6 +64,9 @@ class Zugang: #: Der Text, mit dem der Server die Verbindung abgelehnt hat -- er nennt #: dann kein Verfahren, sondern eine 0 und einen Grund. absage: str = "" + #: Das Verfahren, mit dem zuletzt wirklich eine Sitzung zustande kam + #: (0: noch keine). Damit faengt der naechste Versuch an. + bewaehrt: int = 0 @property def browsertauglich(self) -> bool: @@ -350,6 +353,7 @@ def zugang_gemerkt(device: Mapping[str, Any], *, port: int = STANDARD_PORT) -> Z int(teil) for teil in gespeichert.split(",") if teil.strip().isdigit() ), absage=str(device.get("vnc_reason") or ""), + bewaehrt=int(device.get("vnc_method") or 0), ) diff --git a/apps/tesm/src/tesm/templates/tesm/_dashboard_tiles.html b/apps/tesm/src/tesm/templates/tesm/_dashboard_tiles.html index 578d934..7bba097 100644 --- a/apps/tesm/src/tesm/templates/tesm/_dashboard_tiles.html +++ b/apps/tesm/src/tesm/templates/tesm/_dashboard_tiles.html @@ -102,11 +102,25 @@
{% if tile.vnc %} {# Die Sitzung laeuft in TESM selbst: ein Klick, kein - Viewer auf dem Arbeitsplatz, keine Datei. #} - - {{ icon('monitor', size=14) }} VNC - + Viewer auf dem Arbeitsplatz, keine Datei. + + Mit frischer Passwortbestätigung öffnet sie sich hier + im Fenster -- man bleibt bei der Übersicht und sieht + die anderen Geräte weiter. Ohne sie führt der Knopf + auf die Geräteseite, wo danach gefragt wird. #} + {% if vnc_fenster %} + + {% else %} + + {{ icon('monitor', size=14) }} VNC + + {% endif %} {% else %} {% endif %} diff --git a/apps/tesm/src/tesm/templates/tesm/_vnc_flaeche.html b/apps/tesm/src/tesm/templates/tesm/_vnc_flaeche.html new file mode 100644 index 0000000..a59b143 --- /dev/null +++ b/apps/tesm/src/tesm/templates/tesm/_vnc_flaeche.html @@ -0,0 +1,40 @@ +{# Die Bildschirmfläche -- einmal geschrieben, an zwei Orten benutzt: auf der + Geräteseite und im Fenster der Statusübersicht. + + Die Teile finden sich über `data-rolle`, nicht über Kennungen: im Fenster + gäbe es sie sonst zweimal auf derselben Seite, und die zweite wäre die, die + niemand findet. #} +{% macro vnc_flaeche(quelle='') %} +
+ +
+{% endmacro %} + +{% macro vnc_status() %} +
Wird vorbereitet …
+{% endmacro %} + +{% macro vnc_zugangsdaten() %} + +{% endmacro %} + +{# Tasten, die der Arbeitsplatz selbst abfängt und die deshalb einen Knopf + brauchen: Strg+Alt+Entf schaltet der Browser gar nicht erst durch, Alt+F4 + schlösse das Fenster hier statt das Fenster dort. #} +{% macro vnc_tasten() %} + + + +{% endmacro %} diff --git a/apps/tesm/src/tesm/templates/tesm/dashboard.html b/apps/tesm/src/tesm/templates/tesm/dashboard.html index d8c7a71..390799d 100644 --- a/apps/tesm/src/tesm/templates/tesm/dashboard.html +++ b/apps/tesm/src/tesm/templates/tesm/dashboard.html @@ -1,5 +1,6 @@ {% extends "tesm_core/base.html" %} -{% from "tesm_core/macros.html" import search_box %} +{% from "tesm_core/macros.html" import search_box, modal %} +{% from "tesm/_vnc_flaeche.html" import vnc_flaeche, vnc_status, vnc_zugangsdaten, vnc_tasten %} {% block page_title %}Statusübersicht{% endblock %} {% block page_sub %} @@ -19,4 +20,27 @@ data-refresh-interval="20000"> {% include "tesm/_dashboard_tiles.html" %}
+ +{% if vnc_fenster %} + {# Ein Fenster für alle Kacheln: welches Gerät gemeint ist, bringt der Knopf + beim Öffnen mit. Ein Dialog je Kachel wäre dieselbe Sache dutzendfach im + Quelltext -- und dutzendfach eine Sitzung, die niemand aufräumt. #} + {% call modal('vnc-fenster', 'Bildschirm', 'Tastatur und Maus gehen an das Gerät. Die Zwischenablage bleibt getrennt.', 'wide') %} + + {% endcall %} +{% endif %} +{% endblock %} + +{% block scripts %} +{% if vnc_fenster %} + +{% endif %} {% endblock %} diff --git a/apps/tesm/src/tesm/templates/tesm/screen.html b/apps/tesm/src/tesm/templates/tesm/screen.html index 1b890f5..85d83a7 100644 --- a/apps/tesm/src/tesm/templates/tesm/screen.html +++ b/apps/tesm/src/tesm/templates/tesm/screen.html @@ -1,5 +1,6 @@ {% extends "tesm_core/base.html" %} {% from "tesm_core/macros.html" import alert, pill %} +{% from "tesm/_vnc_flaeche.html" import vnc_flaeche, vnc_status, vnc_zugangsdaten, vnc_tasten %} {% block page_title %}Bildschirm: {{ device.name }}{% endblock %} {% block page_sub %}{{ zugang.ziel }}:{{ zugang.port }}{% endblock %} @@ -13,13 +14,6 @@ {% endblock %} {% block content %} -{# Die Zugangsdaten stehen in einem eigenen JSON-Block statt in einem - data-Attribut: ein Attribut steht im Quelltext der Seite zwischen der - Beschriftung und wird beim Kopieren einer Zeile mitgenommen. Beides ist - derselbe Inhalt -- der Unterschied ist, wie leicht er einem versehentlich - unter die Augen kommt. #} - -
{% if zugang.hinderungsgrund %} {{ alert(zugang.hinderungsgrund, 'warning') }} @@ -49,9 +43,7 @@
- - + {{ vnc_tasten() }} {# 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. #} @@ -70,30 +62,10 @@
-
-
Wird vorbereitet …
- - - -
- -
+
+ {{ vnc_status() }} + {{ vnc_zugangsdaten() }} + {{ vnc_flaeche() }} {% if not darf_geheimnis and device.credential_id %}

diff --git a/packages/tesm-core/pyproject.toml b/packages/tesm-core/pyproject.toml index 467cd09..ba907d0 100644 --- a/packages/tesm-core/pyproject.toml +++ b/packages/tesm-core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-core" -version = "2.8.11" +version = "2.8.12" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = [ diff --git a/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css b/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css index 0aed955..54933e8 100644 --- a/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css @@ -1295,6 +1295,9 @@ sprengen; noVNC skaliert das Bild hinein. */ height: min(72vh, 760px); } + /* Im Fenster ist weniger Platz -- und das Fenster soll nicht ueber den + Bildschirmrand hinauswachsen, nur weil das Geraet hochaufloesend ist. */ + dialog.modal .screen__canvas { height: min(62vh, 620px); } .screen__status { font-size: var(--fs-sm); color: var(--text-faint); diff --git a/packages/tesm-core/src/tesm_core/static/tesm_core/js/screen.js b/packages/tesm-core/src/tesm_core/static/tesm_core/js/screen.js index 50c50ff..01953c8 100644 --- a/packages/tesm-core/src/tesm_core/static/tesm_core/js/screen.js +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/js/screen.js @@ -8,6 +8,11 @@ // Die Meldungen sind der eigentliche Wert. Eine Sitzung, die schwarz bleibt, // laesst offen, ob es an der Berechtigung, am Netz, am Server oder am // Passwort liegt -- und wer das nicht sieht, sucht den Fehler in TESM. +// +// **Ein Bauteil, zwei Orte.** Dieselbe Sitzung laeuft auf der Bildschirmseite +// und im Fenster der Statusuebersicht. Sie holt alles, was sie braucht, aus +// einer JSON-Auskunft des Servers -- damit gibt es keine zweite Fassung, die +// man beim naechsten Fund vergisst nachzuziehen. import RFB from "../vendor/novnc/core/rfb.js"; @@ -18,40 +23,19 @@ import RFB from "../vendor/novnc/core/rfb.js"; // // Zwei davon holen nur Auskunft (_fail, _socketClose). Der dritte greift in // die Wahl des Sicherheitsverfahrens ein -- aber erst, **nachdem** eines -// nachweislich nicht getragen hat. +// nachweislich nicht getragen hat, oder weil eines schon einmal 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. +// geraten und falsch. Was jetzt hier steht, ist das Gegenteil einer Vermutung: +// ein Befund aus einer vorherigen Sitzung. 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; - -//: So lange darf "wird aufgebaut" stehen, bevor die Seite selbst nachsieht. -//: -//: Der Server hat eigene Grenzen und meldet sich, wenn das Geraet schweigt -- -//: aber nur, wenn die Leitung dorthin ueberhaupt steht. Kommt die -//: WebSocket-Verbindung gar nicht erst zustande, wartet noVNC ohne jedes -//: Ereignis, und die Seite stuende stumm bis zum Seitenwechsel. Genau so -//: gemeldet: "Verbindung wird aufgebaut ..." und sonst nichts. -const AUFBAU_GRENZE = 12000; -// 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.) +//: 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. let letzterGrund = ""; const originalFail = RFB.prototype._fail; RFB.prototype._fail = function (details) { @@ -59,9 +43,9 @@ RFB.prototype._fail = function (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). +//: 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) { @@ -72,40 +56,61 @@ RFB.prototype._socketClose = function (ereignis) { return originalSocketClose.call(this, ereignis); }; -const wurzel = document.getElementById("screen"); -if (wurzel) { - starten(wurzel); -} +//: 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; -function starten(wurzel) { - const leinwand = document.getElementById("screen-canvas"); - const zeile = document.getElementById("screen-status"); - const abfrage = document.getElementById("screen-credentials"); - const feldBenutzer = document.getElementById("screen-username"); - const feldPasswort = document.getElementById("screen-password"); - const knopfSenden = document.getElementById("screen-send"); - const knopfTrennen = document.getElementById("screen-disconnect"); - const knopfStrg = document.getElementById("screen-cad"); +//: So lange darf "wird aufgebaut" stehen, bevor die Seite selbst nachsieht. +//: Kommt die WebSocket-Verbindung gar nicht erst zustande, wartet noVNC ohne +//: jedes Ereignis, und die Seite stuende stumm bis zum Seitenwechsel. +const AUFBAU_GRENZE = 12000; - const pfad = wurzel.dataset.socket; - const schema = window.location.protocol === "https:" ? "wss:" : "ws:"; - const adresse = `${schema}//${window.location.host}${pfad}`; +//: Tastenkombinationen, die der Arbeitsplatz selbst abfaengt und die deshalb +//: einen Knopf brauchen. X11-Keysyms. +const ALT_LINKS = 0xffe9; +const F4 = 0xffc1; - const vorgabe = JSON.parse( - document.getElementById("screen-credentials-data")?.textContent || "{}", - ); +const schema = window.location.protocol === "https:" ? "wss:" : "ws:"; +/** + * Baut eine Sitzung in diesem Behaelter auf. + * + * ``quelle`` ist die Adresse der JSON-Auskunft; alles Weitere steht dort. + * Rueckgabe: eine Funktion, die die Sitzung wieder abbaut. + */ +export function starteSitzung(wurzel, quelle) { + const teil = (rolle) => wurzel.querySelector(`[data-rolle="${rolle}"]`); + const leinwand = teil("leinwand"); + const zeile = teil("status"); + const abfrage = teil("zugangsdaten"); + const feldBenutzer = teil("benutzer"); + const feldPasswort = teil("passwort"); + const knopfSenden = teil("anmelden"); + const knopfTrennen = teil("trennen"); + const knoepfeSitzung = [...wurzel.querySelectorAll("[data-rolle^='taste-']"), knopfTrennen]; + + const gescheitert = new Set(); let rfb = null; let aufbauUhr = 0; let versuche = 0; let stand = false; + let daten = null; let letzterZugang = {}; + let beendet = false; function melden(text, art = "") { + if (!zeile) return; zeile.textContent = text; zeile.className = `screen__status${art ? " screen__status--" + art : ""}`; } + function sitzungsknoepfe(sichtbar) { + knoepfeSitzung.forEach((knopf) => { + if (knopf) knopf.hidden = !sichtbar; + }); + } + function grund() { const teile = []; if (letzterGrund) { @@ -120,29 +125,6 @@ function starten(wurzel) { 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 @@ -168,7 +150,45 @@ function starten(wurzel) { }); } + // 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. + 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; + } + + function verfahrenMelden(gewaehlt) { + if (!gewaehlt || gewaehlt === daten.bewaehrt || !daten.meldeweg) return; + fetch(daten.meldeweg, { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "X-Requested-With": "fetch", + "X-CSRF-Token": document.body?.dataset.csrf || "", + }, + body: JSON.stringify({ verfahren: gewaehlt }), + }).catch(() => { + /* Eine Merkhilfe, kein Teil der Sitzung. */ + }); + } + function verbinden(zugang) { + if (beendet) return; melden(versuche ? "Nächster Versuch …" : "Verbindung wird aufgebaut …"); letzterGrund = ""; letzterCode = 0; @@ -176,8 +196,6 @@ function starten(wurzel) { versuche += 1; window.clearTimeout(aufbauUhr); aufbauUhr = window.setTimeout(() => { - // Weder verbunden noch abgebrochen noch nach Zugangsdaten gefragt -- - // dann haengt es an der Leitung, nicht am VNC-Server. melden("Es kommt keine Antwort. Wird geprüft, woran es liegt …", "warnung"); streckeTesten().then((steht) => { melden( @@ -194,15 +212,22 @@ function starten(wurzel) { }); }, AUFBAU_GRENZE); - // Was abgerissen ist, wird nicht noch einmal gewaehlt. Sonst faende noVNC - // dasselbe Verfahren wieder zuerst -- und der zweite Anlauf waere eine - // Wiederholung des ersten. + // Zwei Regeln, beide aus Erfahrung und keine aus Vermutung: + // + // 1. Was schon einmal getragen hat, kommt zuerst -- aber nur beim ersten + // Anlauf. Ohne das faengt jede Sitzung wieder mit der ersten Wahl des + // Servers an, die hier schon abgerissen ist, und ein sperrender + // RealVNC Server laesst sich mit der Absage Minuten Zeit. + // 2. Was abgerissen ist, wird nicht noch einmal gewaehlt. + const bewaehrt = daten.bewaehrt || 0; + const zuerst = versuche === 1 && bewaehrt > 0 && !gescheitert.has(bewaehrt); RFB.prototype._isSupportedSecurityType = function (typ) { if (gescheitert.has(typ)) return false; + if (zuerst) return typ === bewaehrt; return istUnterstuetzt.call(this, typ); }; - rfb = new RFB(leinwand, adresse, { + rfb = new RFB(leinwand, `${schema}//${window.location.host}${daten.socket}`, { credentials: zugang, // Eine Bildschirmsitzung soll nicht nebenbei die Zwischenablage des // Arbeitsplatzes mit dem Geraet teilen -- das ist eine eigene @@ -216,22 +241,20 @@ function starten(wurzel) { rfb.addEventListener("connect", () => { window.clearTimeout(aufbauUhr); stand = true; - melden(`Verbunden mit ${wurzel.dataset.ziel}`, "ok"); - abfrage.hidden = true; + verfahrenMelden(rfb && rfb._rfbAuthScheme > 0 ? rfb._rfbAuthScheme : 0); + melden(`Verbunden mit ${daten.ziel}`, "ok"); + if (abfrage) abfrage.hidden = true; leinwand.hidden = false; - knopfTrennen.hidden = false; - knopfStrg.hidden = false; + sitzungsknoepfe(true); }); rfb.addEventListener("disconnect", (ereignis) => { window.clearTimeout(aufbauUhr); leinwand.hidden = true; - knopfTrennen.hidden = true; - knopfStrg.hidden = true; + sitzungsknoepfe(false); if (ereignis.detail && ereignis.detail.clean) { // 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. + // dass TESM wegen Untätigkeit getrennt hat. melden(letzterGrund ? `Sitzung beendet – ${letzterGrund}` : "Sitzung beendet."); } else if (!stand && naechsterVersuch()) { return; @@ -243,9 +266,6 @@ function starten(wurzel) { : "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( @@ -269,10 +289,10 @@ function starten(wurzel) { rfb.addEventListener("credentialsrequired", (ereignis) => { window.clearTimeout(aufbauUhr); const gefragt = ereignis.detail.types || ["password"]; - abfrage.hidden = false; - feldBenutzer.closest("label").hidden = !gefragt.includes("username"); + if (abfrage) abfrage.hidden = false; + if (feldBenutzer) feldBenutzer.closest("label").hidden = !gefragt.includes("username"); melden("Der VNC-Server verlangt Zugangsdaten.", "warnung"); - (gefragt.includes("username") ? feldBenutzer : feldPasswort).focus(); + (gefragt.includes("username") ? feldBenutzer : feldPasswort)?.focus(); }); rfb.addEventListener("securityfailure", (ereignis) => { @@ -281,44 +301,115 @@ function starten(wurzel) { `Anmeldung abgelehnt: ${ereignis.detail.reason || "kein Grund genannt"}`, "fehler", ); - abfrage.hidden = false; - }); - - rfb.addEventListener("desktopname", (ereignis) => { - wurzel.dataset.desktop = ereignis.detail.name; + if (abfrage) abfrage.hidden = false; }); } knopfSenden?.addEventListener("click", () => { const zugang = {}; - if (feldBenutzer.value) zugang.username = feldBenutzer.value; - if (feldPasswort.value) zugang.password = feldPasswort.value; - if (rfb) { - rfb.sendCredentials(zugang); - } else { - verbinden(zugang); - } + if (feldBenutzer?.value) zugang.username = feldBenutzer.value; + if (feldPasswort?.value) zugang.password = feldPasswort.value; + if (rfb) rfb.sendCredentials(zugang); + else verbinden(zugang); }); [feldBenutzer, feldPasswort].forEach((feld) => feld?.addEventListener("keydown", (ereignis) => { if (ereignis.key === "Enter") { ereignis.preventDefault(); - knopfSenden.click(); + knopfSenden?.click(); } }), ); knopfTrennen?.addEventListener("click", () => rfb && rfb.disconnect()); - knopfStrg?.addEventListener("click", () => rfb && rfb.sendCtrlAltDel()); + teil("taste-cad")?.addEventListener("click", () => rfb && rfb.sendCtrlAltDel()); + // Alt+F4 kennt noVNC nicht als fertigen Griff: der Arbeitsplatz faengt die + // Kombination selbst ab, bevor sie in der Leinwand ankommt. Also einzeln -- + // Alt halten, F4 tippen, Alt loslassen -- wie es auf der Tastatur auch + // geschieht. + teil("taste-altf4")?.addEventListener("click", () => { + if (!rfb) return; + rfb.sendKey(ALT_LINKS, "AltLeft", true); + rfb.sendKey(F4, "F4", true); + rfb.sendKey(F4, "F4", false); + rfb.sendKey(ALT_LINKS, "AltLeft", false); + }); - if (wurzel.dataset.blockiert === "1") { - melden(wurzel.dataset.grund || "Diese Sitzung kann nicht aufgebaut werden.", "fehler"); - return; - } + sitzungsknoepfe(false); + melden("Wird vorbereitet …"); - const zugang = {}; - if (vorgabe.username) zugang.username = vorgabe.username; - if (vorgabe.password) zugang.password = vorgabe.password; - verbinden(zugang); + fetch(quelle, { credentials: "same-origin", headers: { "X-Requested-With": "fetch" } }) + .then((antwort) => antwort.json().then((inhalt) => ({ status: antwort.status, inhalt }))) + .then(({ status, inhalt }) => { + if (beendet) return; + if (status === 409 && inhalt.reauth) { + melden( + "Für den Bildschirm ist eine frische Passwortbestätigung nötig – bitte über " + + "die Geräteseite öffnen.", + "warnung", + ); + return; + } + if (status !== 200) { + melden(inhalt.fehler || "Diese Sitzung lässt sich nicht öffnen.", "fehler"); + return; + } + daten = inhalt; + if (daten.hinderungsgrund) { + melden(daten.hinderungsgrund, "fehler"); + return; + } + const zugang = {}; + if (daten.vorgaben?.username) zugang.username = daten.vorgaben.username; + if (daten.vorgaben?.password) zugang.password = daten.vorgaben.password; + verbinden(zugang); + }) + .catch(() => melden("Die Sitzungsdaten sind nicht abrufbar.", "fehler")); + + return function beenden() { + beendet = true; + window.clearTimeout(aufbauUhr); + try { + rfb?.disconnect(); + } catch (fehler) { + /* schon zu */ + } + rfb = null; + }; +} + +// -- Die beiden Orte -------------------------------------------------------- # + +// 1. Die Bildschirmseite: ein Behaelter, der seine Quelle selbst mitbringt. +const seite = document.getElementById("screen"); +if (seite?.dataset.sitzung) { + starteSitzung(seite, seite.dataset.sitzung); +} + +// 2. Die Statusuebersicht: ein Fenster, das jede Kachel oeffnen kann. Ein +// Dialog fuer alle -- er bekommt beim Oeffnen, welches Geraet gemeint ist. +const fenster = document.getElementById("vnc-fenster"); +if (fenster) { + let beenden = null; + const behaelter = fenster.querySelector("[data-rolle='behaelter']"); + const titel = fenster.querySelector("[data-rolle='titel']"); + + document.addEventListener("click", (ereignis) => { + const knopf = ereignis.target.closest("[data-vnc-oeffnen]"); + if (!knopf) return; + ereignis.preventDefault(); + beenden?.(); + titel.textContent = knopf.dataset.vncName || "Bildschirm"; + if (typeof fenster.showModal === "function") fenster.showModal(); + else fenster.setAttribute("open", ""); + beenden = starteSitzung(behaelter, knopf.dataset.vncOeffnen); + }); + + // Zu heisst zu: eine Sitzung, die im Hintergrund weiterlaeuft, haelt die + // Verbindung zum Geraet und bei RealVNC den einen Sitzungsplatz. + fenster.addEventListener("close", () => { + beenden?.(); + beenden = null; + }); }