Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
914609b758 | ||
|
|
af81c92695 | ||
|
|
a528e9f3b2 |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm"
|
name = "tesm"
|
||||||
version = "2.8.20"
|
version = "2.8.23"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
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.security.sessions import fragment
|
||||||
from tesm_core.extension import core, db
|
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,
|
# Einmal je Seitenaufbau, nicht je Kachel: der Port ist fuer alle derselbe,
|
||||||
# und ohne das Recht wird gar nicht erst nachgesehen.
|
# und ohne das Recht wird gar nicht erst nachgesehen.
|
||||||
vnc_erlaubt = detailed and current_user.has("device_vnc.execute")
|
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 = (
|
vnc_port = (
|
||||||
int(core().settings.get(conn, "vnc_port") or vnc_dienst.STANDARD_PORT)
|
int(core().settings.get(conn, "vnc_port") or vnc_dienst.STANDARD_PORT)
|
||||||
if vnc_erlaubt and core().settings.get(conn, "vnc_enabled")
|
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
|
# Verbindungsversuch je Kachel und Aufbau waere genau die
|
||||||
# Art Hintergrundlast, die niemand bestellt hat.
|
# Art Hintergrundlast, die niemand bestellt hat.
|
||||||
"vnc": bool(vnc_port and row["vnc_open"]),
|
"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)
|
tiles.append(entry)
|
||||||
|
# Merker fuer die Vorlage: nur mit frischer Bestaetigung gibt es das
|
||||||
|
# Fenster; sonst zeigt die Kachel auf die Seite.
|
||||||
|
|
||||||
sections = [
|
sections = [
|
||||||
{
|
{
|
||||||
@@ -142,6 +156,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
|||||||
"counts": counts,
|
"counts": counts,
|
||||||
"sections": sections,
|
"sections": sections,
|
||||||
"detailed": detailed,
|
"detailed": detailed,
|
||||||
|
"vnc_fenster": vnc_fenster,
|
||||||
"interval": int(core().settings.get(conn, "monitor_interval_seconds") or 300),
|
"interval": int(core().settings.get(conn, "monitor_interval_seconds") or 300),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,15 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from typing import Any
|
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 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
|
||||||
@@ -92,15 +100,33 @@ def screen(device_id: int):
|
|||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
zugang = vnc.zugang_gemerkt(device, port=port)
|
zugang = vnc.zugang_gemerkt(device, port=port)
|
||||||
benutzer, passwort = "", ""
|
return render_template(
|
||||||
darf_geheimnis = bool(current_user.has("credentials.secrets"))
|
"tesm/screen.html",
|
||||||
if device.get("credential_id") and darf_geheimnis:
|
device=device,
|
||||||
|
zugang=zugang,
|
||||||
|
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:
|
try:
|
||||||
benutzer, passwort = inventory.credential_secret(
|
benutzer, passwort = inventory.credential_secret(
|
||||||
conn, core().keystore, int(device["credential_id"])
|
conn, core().keystore, int(device["credential_id"])
|
||||||
)
|
)
|
||||||
except inventory.ValidationError:
|
except inventory.ValidationError:
|
||||||
benutzer, passwort = "", ""
|
return "", ""
|
||||||
if passwort:
|
if passwort:
|
||||||
with core().database.transaction(conn):
|
with core().database.transaction(conn):
|
||||||
audit.log(
|
audit.log(
|
||||||
@@ -116,16 +142,7 @@ def screen(device_id: int):
|
|||||||
ip=client_ip(),
|
ip=client_ip(),
|
||||||
severity="warning",
|
severity="warning",
|
||||||
)
|
)
|
||||||
|
return benutzer, passwort
|
||||||
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,
|
|
||||||
verfahren_name=vnc.verfahren_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/monitoring/clients/<int:device_id>/screen/probe")
|
@bp.post("/monitoring/clients/<int:device_id>/screen/probe")
|
||||||
@@ -165,6 +182,73 @@ def probe(device_id: int):
|
|||||||
return redirect(url_for("screen.screen", device_id=device_id))
|
return redirect(url_for("screen.screen", device_id=device_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/monitoring/clients/<int:device_id>/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/<int:device_id>/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]
|
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)
|
||||||
@@ -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
|
# Stattdessen ein kurzer Takt: das Lesen laeuft immer wieder in ein
|
||||||
# Zeitlimit, und *das* ist kein Abbruch, sondern der Anlass nachzusehen,
|
# Zeitlimit, und *das* ist kein Abbruch, sondern der Anlass nachzusehen,
|
||||||
# ob die Sitzung ueberhaupt noch etwas tut.
|
# 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)
|
gegenstelle.settimeout(TAKT)
|
||||||
leerlauf = int(extension.settings.get(conn, "vnc_idle_timeout") or 0)
|
leerlauf = int(extension.settings.get(conn, "vnc_idle_timeout") or 0)
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
|
||||||
"""Aus sieben Standardgruppen werden drei -- ohne jemandem etwas wegzunehmen.
|
"""Aus sieben Standardgruppen werden drei -- ohne jemandem etwas wegzunehmen.
|
||||||
|
|
||||||
@@ -585,4 +598,5 @@ TESM_MIGRATIONS: tuple[Migration, ...] = (
|
|||||||
Migration(version=11, name="vnc-ziel", sql=V11),
|
Migration(version=11, name="vnc-ziel", sql=V11),
|
||||||
Migration(version=12, name="vnc-verfahren", sql=V12),
|
Migration(version=12, name="vnc-verfahren", sql=V12),
|
||||||
Migration(version=13, name="vnc-absage", sql=V13),
|
Migration(version=13, name="vnc-absage", sql=V13),
|
||||||
|
Migration(version=14, name="vnc-verfahren-das-traegt", sql=V14),
|
||||||
)
|
)
|
||||||
@@ -545,7 +545,8 @@ def list_devices(
|
|||||||
st.fail_count, COALESCE(st.vnc_open, 0) AS vnc_open, st.vnc_checked_at,
|
st.fail_count, COALESCE(st.vnc_open, 0) AS vnc_open, st.vnc_checked_at,
|
||||||
COALESCE(st.vnc_target, '') AS vnc_target,
|
COALESCE(st.vnc_target, '') AS vnc_target,
|
||||||
COALESCE(st.vnc_types, '') AS vnc_types,
|
COALESCE(st.vnc_types, '') AS vnc_types,
|
||||||
COALESCE(st.vnc_reason, '') AS vnc_reason, st.vnc_types_at
|
COALESCE(st.vnc_reason, '') AS vnc_reason, st.vnc_types_at,
|
||||||
|
COALESCE(st.vnc_method, 0) AS vnc_method
|
||||||
FROM devices d
|
FROM devices d
|
||||||
LEFT JOIN switches s ON s.id = d.switch_id AND s.deleted_at IS NULL
|
LEFT JOIN switches s ON s.id = d.switch_id AND s.deleted_at IS NULL
|
||||||
LEFT JOIN credentials c ON c.id = d.credential_id AND c.deleted_at IS NULL
|
LEFT JOIN credentials c ON c.id = d.credential_id AND c.deleted_at IS NULL
|
||||||
|
|||||||
@@ -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(
|
def run_sweep(
|
||||||
*,
|
*,
|
||||||
database: Any,
|
database: Any,
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ class Zugang:
|
|||||||
#: Der Text, mit dem der Server die Verbindung abgelehnt hat -- er nennt
|
#: Der Text, mit dem der Server die Verbindung abgelehnt hat -- er nennt
|
||||||
#: dann kein Verfahren, sondern eine 0 und einen Grund.
|
#: dann kein Verfahren, sondern eine 0 und einen Grund.
|
||||||
absage: str = ""
|
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
|
@property
|
||||||
def browsertauglich(self) -> bool:
|
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()
|
int(teil) for teil in gespeichert.split(",") if teil.strip().isdigit()
|
||||||
),
|
),
|
||||||
absage=str(device.get("vnc_reason") or ""),
|
absage=str(device.get("vnc_reason") or ""),
|
||||||
|
bewaehrt=int(device.get("vnc_method") or 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -102,11 +102,25 @@
|
|||||||
<div class="tile__actions">
|
<div class="tile__actions">
|
||||||
{% if tile.vnc %}
|
{% if tile.vnc %}
|
||||||
{# Die Sitzung laeuft in TESM selbst: ein Klick, kein
|
{# Die Sitzung laeuft in TESM selbst: ein Klick, kein
|
||||||
Viewer auf dem Arbeitsplatz, keine Datei. #}
|
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 %}
|
||||||
|
<button class="btn btn--sm" type="button"
|
||||||
|
data-vnc-oeffnen="{{ tile.vnc_quelle }}"
|
||||||
|
data-vnc-name="{{ tile.name }}"
|
||||||
|
title="Bildschirm im Fenster öffnen">
|
||||||
|
{{ icon('monitor', size=14) }} VNC
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
<a class="btn btn--sm" href="{{ url_for('screen.screen', device_id=tile.id) }}"
|
<a class="btn btn--sm" href="{{ url_for('screen.screen', device_id=tile.id) }}"
|
||||||
title="Bildschirm im Browser öffnen">
|
title="Bildschirm im Browser öffnen">
|
||||||
{{ icon('monitor', size=14) }} VNC
|
{{ icon('monitor', size=14) }} VNC
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="tile__spacer" aria-hidden="true"></span>
|
<span class="tile__spacer" aria-hidden="true"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -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='') %}
|
||||||
|
<div class="screen" {% if quelle %}data-sitzung="{{ quelle }}"{% endif %}>
|
||||||
|
<div data-rolle="leinwand" class="screen__canvas" hidden></div>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro vnc_status() %}
|
||||||
|
<div data-rolle="status" class="screen__status">Wird vorbereitet …</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro vnc_zugangsdaten() %}
|
||||||
|
<form data-rolle="zugangsdaten" class="row row--tight" hidden onsubmit="return false">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">Benutzer</span>
|
||||||
|
<input class="input" type="text" data-rolle="benutzer" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">Passwort</span>
|
||||||
|
<input class="input" type="password" data-rolle="passwort" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<button class="btn btn--primary" type="button" data-rolle="anmelden">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
{% 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() %}
|
||||||
|
<button class="btn btn--sm" type="button" data-rolle="taste-cad" hidden
|
||||||
|
title="Strg+Alt+Entf an das Gerät senden">Strg+Alt+Entf</button>
|
||||||
|
<button class="btn btn--sm" type="button" data-rolle="taste-altf4" hidden
|
||||||
|
title="Alt+F4 an das Gerät senden – schließt dort das oberste Fenster">Alt+F4</button>
|
||||||
|
<button class="btn btn--sm" type="button" data-rolle="trennen" hidden>Trennen</button>
|
||||||
|
{% endmacro %}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends "tesm_core/base.html" %}
|
{% 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_title %}Statusübersicht{% endblock %}
|
||||||
{% block page_sub %}
|
{% block page_sub %}
|
||||||
@@ -19,4 +20,27 @@
|
|||||||
data-refresh-interval="20000">
|
data-refresh-interval="20000">
|
||||||
{% include "tesm/_dashboard_tiles.html" %}
|
{% include "tesm/_dashboard_tiles.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% 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') %}
|
||||||
|
<div class="modal__body stack" data-rolle="behaelter">
|
||||||
|
<div class="row row--tight">
|
||||||
|
<strong data-rolle="titel" class="flex-1"></strong>
|
||||||
|
{{ vnc_tasten() }}
|
||||||
|
</div>
|
||||||
|
{{ vnc_status() }}
|
||||||
|
{{ vnc_zugangsdaten() }}
|
||||||
|
{{ vnc_flaeche() }}
|
||||||
|
</div>
|
||||||
|
{% endcall %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% if vnc_fenster %}
|
||||||
|
<script type="module" src="{{ asset_url('js/screen.js') }}" nonce="{{ csp_nonce }}"></script>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends "tesm_core/base.html" %}
|
{% extends "tesm_core/base.html" %}
|
||||||
{% from "tesm_core/macros.html" import alert, pill %}
|
{% 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_title %}Bildschirm: {{ device.name }}{% endblock %}
|
||||||
{% block page_sub %}{{ zugang.ziel }}:{{ zugang.port }}{% endblock %}
|
{% block page_sub %}{{ zugang.ziel }}:{{ zugang.port }}{% endblock %}
|
||||||
@@ -13,13 +14,6 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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. #}
|
|
||||||
<script type="application/json" id="screen-credentials-data">{{ vorgaben | tojson }}</script>
|
|
||||||
|
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
{% if zugang.hinderungsgrund %}
|
{% if zugang.hinderungsgrund %}
|
||||||
{{ alert(zugang.hinderungsgrund, 'warning') }}
|
{{ alert(zugang.hinderungsgrund, 'warning') }}
|
||||||
@@ -49,9 +43,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row row--tight">
|
<div class="row row--tight">
|
||||||
<button class="btn btn--sm" type="button" id="screen-cad" hidden
|
{{ vnc_tasten() }}
|
||||||
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
|
{# Ausdrücklich ein Knopf und keine Nebenwirkung des Seitenaufbaus: die
|
||||||
Prüfung bricht eine Protokollverhandlung ab, und RealVNC zählt das.
|
Prüfung bricht eine Protokollverhandlung ab, und RealVNC zählt das.
|
||||||
Wer sucht, drückt ihn; wer nur arbeiten will, merkt nichts davon. #}
|
Wer sucht, drückt ihn; wer nur arbeiten will, merkt nichts davon. #}
|
||||||
@@ -59,7 +51,9 @@
|
|||||||
action="{{ url_for('screen.probe', device_id=device.id) }}">
|
action="{{ url_for('screen.probe', device_id=device.id) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm btn--ghost" type="submit"
|
<button class="btn btn--sm btn--ghost" type="submit"
|
||||||
title="Fragt das Gerät, welche Anmeldeverfahren es anbietet">
|
title="Fragt das Gerät, welche Anmeldeverfahren es anbietet. Das ist
|
||||||
|
eine eigene Verbindung, die abgebrochen wird – ein RealVNC Server zählt so etwas mit.
|
||||||
|
Nicht unmittelbar vor dem Verbinden drücken.">
|
||||||
Verfahren prüfen
|
Verfahren prüfen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -70,30 +64,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card__body stack">
|
<div class="card__body stack" id="screen" data-sitzung="{{ quelle }}">
|
||||||
<div id="screen-status" class="screen__status">Wird vorbereitet …</div>
|
{{ vnc_status() }}
|
||||||
|
{{ vnc_zugangsdaten() }}
|
||||||
<form id="screen-credentials" class="row row--tight" hidden onsubmit="return false">
|
{{ vnc_flaeche() }}
|
||||||
<label class="field">
|
|
||||||
<span class="field__label">Benutzer</span>
|
|
||||||
<input class="input" type="text" id="screen-username" autocomplete="off"
|
|
||||||
value="{{ vorgaben.username }}">
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span class="field__label">Passwort</span>
|
|
||||||
<input class="input" type="password" id="screen-password" autocomplete="off">
|
|
||||||
</label>
|
|
||||||
<button class="btn btn--primary" type="button" id="screen-send">Anmelden</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if not darf_geheimnis and device.credential_id %}
|
{% if not darf_geheimnis and device.credential_id %}
|
||||||
<p class="fs-xs faint">
|
<p class="fs-xs faint">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.8.10"
|
version = "2.8.13"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -1295,6 +1295,9 @@
|
|||||||
sprengen; noVNC skaliert das Bild hinein. */
|
sprengen; noVNC skaliert das Bild hinein. */
|
||||||
height: min(72vh, 760px);
|
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 {
|
.screen__status {
|
||||||
font-size: var(--fs-sm);
|
font-size: var(--fs-sm);
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
|
|||||||
@@ -8,6 +8,11 @@
|
|||||||
// Die Meldungen sind der eigentliche Wert. Eine Sitzung, die schwarz bleibt,
|
// Die Meldungen sind der eigentliche Wert. Eine Sitzung, die schwarz bleibt,
|
||||||
// laesst offen, ob es an der Berechtigung, am Netz, am Server oder am
|
// 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.
|
// 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";
|
import RFB from "../vendor/novnc/core/rfb.js";
|
||||||
|
|
||||||
@@ -18,31 +23,19 @@ import RFB from "../vendor/novnc/core/rfb.js";
|
|||||||
//
|
//
|
||||||
// Zwei davon holen nur Auskunft (_fail, _socketClose). Der dritte greift in
|
// Zwei davon holen nur Auskunft (_fail, _socketClose). Der dritte greift in
|
||||||
// die Wahl des Sicherheitsverfahrens ein -- aber erst, **nachdem** eines
|
// 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
|
// Die Vorgeschichte gehoert dazu, damit niemand den Schritt zurueckgeht: hier
|
||||||
// stand einmal eine Vorauswahl, die ohne hinterlegte Zugangsdaten ein
|
// stand einmal eine Vorauswahl, die ohne hinterlegte Zugangsdaten ein
|
||||||
// Verfahren ohne Anmeldung erzwang, weil RealVNC RA2ne zuerst nennt. Das war
|
// Verfahren ohne Anmeldung erzwang, weil RealVNC RA2ne zuerst nennt. Das war
|
||||||
// geraten und falsch. Was jetzt hier steht, ist das Gegenteil einer
|
// geraten und falsch. Was jetzt hier steht, ist das Gegenteil einer Vermutung:
|
||||||
// Vermutung: der Server nennt seine Reihenfolge, die gilt -- und wenn seine
|
// ein Befund aus einer vorherigen Sitzung.
|
||||||
// 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.
|
//: Den *Grund* eines Fehlschlags kennt nur ``_fail`` -- das Ereignis
|
||||||
const gescheitert = new Set();
|
//: "disconnect" traegt ihn nicht, und der Weg ueber die Protokollausgabe
|
||||||
|
//: funktioniert nicht: die Bibliothek bindet ``console.error`` beim Laden,
|
||||||
//: Hoechstens ein zweiter Anlauf. Jeder Versuch ist eine Verbindung, und ein
|
//: also bevor eine Seite etwas daran aendern koennte.
|
||||||
//: 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 = "";
|
let letzterGrund = "";
|
||||||
const originalFail = RFB.prototype._fail;
|
const originalFail = RFB.prototype._fail;
|
||||||
RFB.prototype._fail = function (details) {
|
RFB.prototype._fail = function (details) {
|
||||||
@@ -50,9 +43,9 @@ RFB.prototype._fail = function (details) {
|
|||||||
return originalFail.call(this, details);
|
return originalFail.call(this, details);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Und der Abschluss der Leitung selbst: der Schliesscode sagt, ob TESM
|
//: Und der Abschluss der Leitung selbst: der Schliesscode sagt, ob TESM
|
||||||
// abgewiesen hat (1008), ob es ein Serverfehler war (1011) -- oder ob die
|
//: abgewiesen hat (1008), ob es ein Serverfehler war (1011) -- oder ob die
|
||||||
// Verbindung nie zustande kam (1006).
|
//: Verbindung nie zustande kam (1006).
|
||||||
let letzterCode = 0;
|
let letzterCode = 0;
|
||||||
const originalSocketClose = RFB.prototype._socketClose;
|
const originalSocketClose = RFB.prototype._socketClose;
|
||||||
RFB.prototype._socketClose = function (ereignis) {
|
RFB.prototype._socketClose = function (ereignis) {
|
||||||
@@ -63,39 +56,79 @@ RFB.prototype._socketClose = function (ereignis) {
|
|||||||
return originalSocketClose.call(this, ereignis);
|
return originalSocketClose.call(this, ereignis);
|
||||||
};
|
};
|
||||||
|
|
||||||
const wurzel = document.getElementById("screen");
|
//: Hoechstens ein zweiter Anlauf. Jeder Versuch ist eine Verbindung, und ein
|
||||||
if (wurzel) {
|
//: RealVNC Server zaehlt abgebrochene Versuche mit -- wer dreimal anklopft,
|
||||||
starten(wurzel);
|
//: sperrt sich aus.
|
||||||
}
|
const HOECHSTENS = 2;
|
||||||
|
|
||||||
function starten(wurzel) {
|
//: So lange darf "wird aufgebaut" stehen, bevor die Seite selbst nachsieht.
|
||||||
const leinwand = document.getElementById("screen-canvas");
|
//: Kommt die WebSocket-Verbindung gar nicht erst zustande, wartet noVNC ohne
|
||||||
const zeile = document.getElementById("screen-status");
|
//: jedes Ereignis, und die Seite stuende stumm bis zum Seitenwechsel.
|
||||||
const abfrage = document.getElementById("screen-credentials");
|
const AUFBAU_GRENZE = 12000;
|
||||||
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");
|
|
||||||
|
|
||||||
const pfad = wurzel.dataset.socket;
|
//: Verfahren, die im Browser keinen Schluesseltausch kosten.
|
||||||
const schema = window.location.protocol === "https:" ? "wss:" : "ws:";
|
//:
|
||||||
const adresse = `${schema}//${window.location.host}${pfad}`;
|
//: Der Grund steht in der Bibliothek, `core/ra2.js`: fuer RA2ne erzeugt der
|
||||||
|
//: **Browser** bei jeder Sitzung ein eigenes 2048-Bit-RSA-Schluesselpaar, in
|
||||||
|
//: JavaScript. Die Primzahlsuche dafuer dauert ein bis drei Minuten -- genau
|
||||||
|
//: die Zeit, die zwischen Klick und Bild lag. "Ohne Authentifizierung" (1)
|
||||||
|
//: kostet davon nichts.
|
||||||
|
//:
|
||||||
|
//: Deshalb kommt es zuerst, **wenn der Server es anbietet** und wir nicht
|
||||||
|
//: ohnehin schon wissen, was hier traegt. Das ist nicht dieselbe Vorauswahl
|
||||||
|
//: wie in 2.8.14: die war geraten, diese steht auf einer Messung. Traegt sie
|
||||||
|
//: nicht, nimmt der zweite Anlauf die Reihenfolge des Servers -- dann dauert
|
||||||
|
//: es eben.
|
||||||
|
const OHNE_SCHLUESSELTAUSCH = new Set([1]);
|
||||||
|
|
||||||
const vorgabe = JSON.parse(
|
//: RA2ne. Wird es ausgehandelt, ist die Wartezeit erklaerbar und kein Fehler.
|
||||||
document.getElementById("screen-credentials-data")?.textContent || "{}",
|
const RA2NE = 6;
|
||||||
);
|
|
||||||
|
|
||||||
|
//: Tastenkombinationen, die der Arbeitsplatz selbst abfaengt und die deshalb
|
||||||
|
//: einen Knopf brauchen. X11-Keysyms.
|
||||||
|
const ALT_LINKS = 0xffe9;
|
||||||
|
const F4 = 0xffc1;
|
||||||
|
|
||||||
|
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 rfb = null;
|
||||||
|
let aufbauUhr = 0;
|
||||||
let versuche = 0;
|
let versuche = 0;
|
||||||
let stand = false;
|
let stand = false;
|
||||||
|
let daten = null;
|
||||||
let letzterZugang = {};
|
let letzterZugang = {};
|
||||||
|
let beendet = false;
|
||||||
|
|
||||||
function melden(text, art = "") {
|
function melden(text, art = "") {
|
||||||
|
if (!zeile) return;
|
||||||
zeile.textContent = text;
|
zeile.textContent = text;
|
||||||
zeile.className = `screen__status${art ? " screen__status--" + art : ""}`;
|
zeile.className = `screen__status${art ? " screen__status--" + art : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sitzungsknoepfe(sichtbar) {
|
||||||
|
knoepfeSitzung.forEach((knopf) => {
|
||||||
|
if (knopf) knopf.hidden = !sichtbar;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function grund() {
|
function grund() {
|
||||||
const teile = [];
|
const teile = [];
|
||||||
if (letzterGrund) {
|
if (letzterGrund) {
|
||||||
@@ -110,29 +143,6 @@ function starten(wurzel) {
|
|||||||
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
|
||||||
@@ -158,22 +168,105 @@ 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) {
|
function verbinden(zugang) {
|
||||||
|
if (beendet) return;
|
||||||
melden(versuche ? "Nächster Versuch …" : "Verbindung wird aufgebaut …");
|
melden(versuche ? "Nächster Versuch …" : "Verbindung wird aufgebaut …");
|
||||||
letzterGrund = "";
|
letzterGrund = "";
|
||||||
letzterCode = 0;
|
letzterCode = 0;
|
||||||
letzterZugang = zugang;
|
letzterZugang = zugang;
|
||||||
versuche += 1;
|
versuche += 1;
|
||||||
|
window.clearTimeout(aufbauUhr);
|
||||||
|
aufbauUhr = window.setTimeout(() => {
|
||||||
|
// Ein ausgehandeltes RA2ne ist kein Fehler, sondern Rechenzeit: der
|
||||||
|
// Browser erzeugt dafuer gerade einen 2048-Bit-Schluessel. Das zu
|
||||||
|
// melden ist das Gegenteil einer Warnung -- es nimmt die Sorge.
|
||||||
|
if (rfb && rfb._rfbAuthScheme === RA2NE) {
|
||||||
|
melden(
|
||||||
|
"Der Server verlangt RA2ne (RSA-AES). Dafür erzeugt der Browser ein " +
|
||||||
|
"2048-Bit-Schlüsselpaar – das dauert ein bis drei Minuten. Bitte warten.",
|
||||||
|
"warnung",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
melden("Es kommt keine Antwort. Wird geprüft, woran es liegt …", "warnung");
|
||||||
|
streckeTesten().then((steht) => {
|
||||||
|
melden(
|
||||||
|
steht
|
||||||
|
? "Die WebSocket-Strecke zu TESM steht, aber die Sitzung kommt nicht " +
|
||||||
|
"zustande. Antwortet der VNC-Server auf dem Gerät noch? Ein Neustart " +
|
||||||
|
"des VNC-Dienstes räumt eine Sperre ab."
|
||||||
|
: "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).",
|
||||||
|
"fehler",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, AUFBAU_GRENZE);
|
||||||
|
|
||||||
// Was abgerissen ist, wird nicht noch einmal gewaehlt. Sonst faende noVNC
|
// Drei Regeln, alle aus Messung und keine aus Vermutung:
|
||||||
// dasselbe Verfahren wieder zuerst -- und der zweite Anlauf waere eine
|
//
|
||||||
// Wiederholung des ersten.
|
// 1. Was schon einmal getragen hat, kommt zuerst.
|
||||||
|
// 2. Sonst das Verfahren ohne Schluesseltausch -- aber nur, wenn der
|
||||||
|
// Server es nachweislich anbietet (sonst faende noVNC gar keines und
|
||||||
|
// scheiterte sofort). RA2ne kostet sonst ein bis drei Minuten
|
||||||
|
// Rechenzeit im Browser, jedes Mal.
|
||||||
|
// 3. Was abgerissen ist, wird nicht noch einmal gewaehlt.
|
||||||
|
//
|
||||||
|
// Beides gilt nur fuer den **ersten** Anlauf; danach zaehlt die
|
||||||
|
// Reihenfolge des Servers, abzueglich dessen, was schon abgerissen ist.
|
||||||
|
const bewaehrt = daten.bewaehrt || 0;
|
||||||
|
const angeboten = daten.verfahren || [];
|
||||||
|
const schnell = angeboten.filter((typ) => OHNE_SCHLUESSELTAUSCH.has(typ));
|
||||||
|
let bevorzugt = 0;
|
||||||
|
if (versuche === 1) {
|
||||||
|
if (bewaehrt > 0 && !gescheitert.has(bewaehrt)) bevorzugt = bewaehrt;
|
||||||
|
else if (schnell.length && !gescheitert.has(schnell[0])) bevorzugt = schnell[0];
|
||||||
|
}
|
||||||
RFB.prototype._isSupportedSecurityType = function (typ) {
|
RFB.prototype._isSupportedSecurityType = function (typ) {
|
||||||
if (gescheitert.has(typ)) return false;
|
if (gescheitert.has(typ)) return false;
|
||||||
|
if (bevorzugt) return typ === bevorzugt;
|
||||||
return istUnterstuetzt.call(this, typ);
|
return istUnterstuetzt.call(this, typ);
|
||||||
};
|
};
|
||||||
|
|
||||||
rfb = new RFB(leinwand, adresse, {
|
rfb = new RFB(leinwand, `${schema}//${window.location.host}${daten.socket}`, {
|
||||||
credentials: zugang,
|
credentials: zugang,
|
||||||
// Eine Bildschirmsitzung soll nicht nebenbei die Zwischenablage des
|
// Eine Bildschirmsitzung soll nicht nebenbei die Zwischenablage des
|
||||||
// Arbeitsplatzes mit dem Geraet teilen -- das ist eine eigene
|
// Arbeitsplatzes mit dem Geraet teilen -- das ist eine eigene
|
||||||
@@ -185,22 +278,22 @@ function starten(wurzel) {
|
|||||||
rfb.focusOnClick = true;
|
rfb.focusOnClick = true;
|
||||||
|
|
||||||
rfb.addEventListener("connect", () => {
|
rfb.addEventListener("connect", () => {
|
||||||
|
window.clearTimeout(aufbauUhr);
|
||||||
stand = true;
|
stand = true;
|
||||||
melden(`Verbunden mit ${wurzel.dataset.ziel}`, "ok");
|
verfahrenMelden(rfb && rfb._rfbAuthScheme > 0 ? rfb._rfbAuthScheme : 0);
|
||||||
abfrage.hidden = true;
|
melden(`Verbunden mit ${daten.ziel}`, "ok");
|
||||||
|
if (abfrage) abfrage.hidden = true;
|
||||||
leinwand.hidden = false;
|
leinwand.hidden = false;
|
||||||
knopfTrennen.hidden = false;
|
sitzungsknoepfe(true);
|
||||||
knopfStrg.hidden = false;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
rfb.addEventListener("disconnect", (ereignis) => {
|
rfb.addEventListener("disconnect", (ereignis) => {
|
||||||
|
window.clearTimeout(aufbauUhr);
|
||||||
leinwand.hidden = true;
|
leinwand.hidden = true;
|
||||||
knopfTrennen.hidden = true;
|
sitzungsknoepfe(false);
|
||||||
knopfStrg.hidden = true;
|
|
||||||
if (ereignis.detail && ereignis.detail.clean) {
|
if (ereignis.detail && ereignis.detail.clean) {
|
||||||
// Beim sauberen Schluss steht der Grund im Schliessrahmen -- etwa,
|
// Beim sauberen Schluss steht der Grund im Schliessrahmen -- etwa,
|
||||||
// dass TESM wegen Untätigkeit getrennt hat. Ohne ihn stuende da
|
// dass TESM wegen Untätigkeit getrennt hat.
|
||||||
// "beendet", und niemand wuesste, warum das Bild weg ist.
|
|
||||||
melden(letzterGrund ? `Sitzung beendet – ${letzterGrund}` : "Sitzung beendet.");
|
melden(letzterGrund ? `Sitzung beendet – ${letzterGrund}` : "Sitzung beendet.");
|
||||||
} else if (!stand && naechsterVersuch()) {
|
} else if (!stand && naechsterVersuch()) {
|
||||||
return;
|
return;
|
||||||
@@ -212,9 +305,6 @@ function starten(wurzel) {
|
|||||||
: "Verbindung abgebrochen. Wird geprüft, woran es liegt …",
|
: "Verbindung abgebrochen. Wird geprüft, woran es liegt …",
|
||||||
"fehler",
|
"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) {
|
if (letzterCode === 1006 || !erklaerung) {
|
||||||
streckeTesten().then((steht) => {
|
streckeTesten().then((steht) => {
|
||||||
melden(
|
melden(
|
||||||
@@ -236,56 +326,129 @@ function starten(wurzel) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
rfb.addEventListener("credentialsrequired", (ereignis) => {
|
rfb.addEventListener("credentialsrequired", (ereignis) => {
|
||||||
|
window.clearTimeout(aufbauUhr);
|
||||||
const gefragt = ereignis.detail.types || ["password"];
|
const gefragt = ereignis.detail.types || ["password"];
|
||||||
abfrage.hidden = false;
|
if (abfrage) abfrage.hidden = false;
|
||||||
feldBenutzer.closest("label").hidden = !gefragt.includes("username");
|
if (feldBenutzer) feldBenutzer.closest("label").hidden = !gefragt.includes("username");
|
||||||
melden("Der VNC-Server verlangt Zugangsdaten.", "warnung");
|
melden("Der VNC-Server verlangt Zugangsdaten.", "warnung");
|
||||||
(gefragt.includes("username") ? feldBenutzer : feldPasswort).focus();
|
(gefragt.includes("username") ? feldBenutzer : feldPasswort)?.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
rfb.addEventListener("securityfailure", (ereignis) => {
|
rfb.addEventListener("securityfailure", (ereignis) => {
|
||||||
|
window.clearTimeout(aufbauUhr);
|
||||||
melden(
|
melden(
|
||||||
`Anmeldung abgelehnt: ${ereignis.detail.reason || "kein Grund genannt"}`,
|
`Anmeldung abgelehnt: ${ereignis.detail.reason || "kein Grund genannt"}`,
|
||||||
"fehler",
|
"fehler",
|
||||||
);
|
);
|
||||||
abfrage.hidden = false;
|
if (abfrage) abfrage.hidden = false;
|
||||||
});
|
|
||||||
|
|
||||||
rfb.addEventListener("desktopname", (ereignis) => {
|
|
||||||
wurzel.dataset.desktop = ereignis.detail.name;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
knopfSenden?.addEventListener("click", () => {
|
knopfSenden?.addEventListener("click", () => {
|
||||||
const zugang = {};
|
const zugang = {};
|
||||||
if (feldBenutzer.value) zugang.username = feldBenutzer.value;
|
if (feldBenutzer?.value) zugang.username = feldBenutzer.value;
|
||||||
if (feldPasswort.value) zugang.password = feldPasswort.value;
|
if (feldPasswort?.value) zugang.password = feldPasswort.value;
|
||||||
if (rfb) {
|
if (rfb) rfb.sendCredentials(zugang);
|
||||||
rfb.sendCredentials(zugang);
|
else verbinden(zugang);
|
||||||
} else {
|
|
||||||
verbinden(zugang);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
[feldBenutzer, feldPasswort].forEach((feld) =>
|
[feldBenutzer, feldPasswort].forEach((feld) =>
|
||||||
feld?.addEventListener("keydown", (ereignis) => {
|
feld?.addEventListener("keydown", (ereignis) => {
|
||||||
if (ereignis.key === "Enter") {
|
if (ereignis.key === "Enter") {
|
||||||
ereignis.preventDefault();
|
ereignis.preventDefault();
|
||||||
knopfSenden.click();
|
knopfSenden?.click();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
knopfTrennen?.addEventListener("click", () => rfb && rfb.disconnect());
|
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") {
|
sitzungsknoepfe(false);
|
||||||
melden(wurzel.dataset.grund || "Diese Sitzung kann nicht aufgebaut werden.", "fehler");
|
melden("Wird vorbereitet …");
|
||||||
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const zugang = {};
|
const zugang = {};
|
||||||
if (vorgabe.username) zugang.username = vorgabe.username;
|
if (daten.vorgaben?.username) zugang.username = daten.vorgaben.username;
|
||||||
if (vorgabe.password) zugang.password = vorgabe.password;
|
if (daten.vorgaben?.password) zugang.password = daten.vorgaben.password;
|
||||||
verbinden(zugang);
|
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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user