Compare commits

...
7 Commits
23 changed files with 1218 additions and 240 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm"
version = "2.8.18"
version = "2.8.25"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11"
dependencies = [
+16 -1
View File
@@ -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),
}
+8 -6
View File
@@ -411,12 +411,14 @@ def vnc_connection(device_id: int):
ziel = vnc.ziel(device)
if not ziel:
abort(404)
with transaction():
_audit(
conn,
"device.vnc_opened",
str(device["name"]),
f"Verbindungsdatei für {ziel}:{port}",
# Kein Eintrag in der Aenderungskette: eine Verbindungsdatei ist derselbe
# Vorgang wie die Sitzung im Browser, nur fuer einen Viewer auf dem
# Arbeitsplatz -- und sie aendert nichts. Wer zusieht, steht im laufenden
# Protokoll; das Aenderungsprotokoll bleibt die Liste der Eingriffe.
live = core().registry.get("live_log")
if live is not None:
live.info(
"vnc", f"{current_user.username} lädt die Verbindungsdatei für {device['name']}"
)
return Response(
vnc.verbindungsdatei(
+58 -6
View File
@@ -18,7 +18,7 @@ from __future__ import annotations
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for
from tesm_core.security.sessions import fragment
from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required
from tesm_core.auth.models import current_user, permission_required, reauth_required
from tesm_core.auth.service import client_ip
from tesm_core.db import Database
from tesm_core.extension import core, db, transaction
@@ -77,7 +77,7 @@ def _query_devices(conn) -> list[dict]: # type: ignore[no-untyped-def]
@bp.get("/")
@permission_required(*SERVICING_PAGE, mode="any")
def overview():
def overview(): # noqa: C901 - eine Seite, drei Aufgaben
conn = db()
return render_template(
"tesm/maintenance.html",
@@ -89,6 +89,7 @@ def overview():
terminal_enabled=bool(core().settings.get(conn, "terminal_clients_enabled")),
windows_tool_missing=_windows_tool_missing(conn),
windows_package=windowsops.REQUIRED_PACKAGE,
befehlsvorlagen=service.BEFEHLSVORLAGEN,
)
@@ -150,6 +151,13 @@ def status():
{
"running": running,
"poll_ms": 2000 if running else 10000,
# Der Zustand der Geraete gehoert dazu: die Seite steht sonst mit
# dem Stand ihres Aufbaus da, und wer nach einem Neustart auf die
# Rueckkehr wartet, sieht "unbekannt", bis er von Hand neu laedt.
"devices": [
{"id": int(row["id"]), "state": str(row["state"] or "unknown")}
for row in _eligible_devices(conn)
],
"jobs": [
{
"id": job["id"],
@@ -159,6 +167,16 @@ def status():
"progress": job["progress"],
"message": job["message"],
"updated_at": job["updated_at"],
# Die letzten Zeilen -- nur von dem, was gerade laeuft.
# Mitlesen heisst mitlesen: wer nach der Wartung wissen
# will, was war, oeffnet den Auftrag. Alles mitzuschicken
# hiesse, alle zwei Sekunden das gesamte Protokoll aller
# Geraete zu uebertragen.
"output": (
service.letzte_zeilen(str(job["output"] or ""))
if service.eben_fertig(dict(job))
else ""
),
}
for job in jobs
],
@@ -187,7 +205,7 @@ def job_output(job_id: str):
return render_template("tesm/_maintenance_output.html", job=job)
def _start(kind: str) -> None:
def _start(kind: str, befehl: str = "") -> None:
extension = core()
conn = db()
device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()]
@@ -233,12 +251,27 @@ def _start(kind: str) -> None:
actor_id=current_user.id,
action=kind,
target=str(device["name"]),
detail=f"Auftrag {handle.id}",
# Beim Befehl gehoert sein Wortlaut hierher und nicht nur die
# Auftragsnummer: was gelaufen ist, muss aus dem Protokoll
# hervorgehen, auch wenn der Auftrag laengst aus dem Verlauf
# gerollt ist.
detail=f"Auftrag {handle.id}" + (f": {befehl}" if befehl else ""),
ip=client_ip(),
severity="notice",
severity="warning" if befehl else "notice",
)
if kind == service.JOB_KIND_UPDATE:
if kind == service.JOB_KIND_COMMAND:
service.start_background(
service.run_command,
database=extension.database,
job_id=handle.id,
device=dict(device),
username=username,
password=password,
befehl=befehl,
timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
)
elif kind == service.JOB_KIND_UPDATE:
service.start_background(
service.run_update,
database=extension.database,
@@ -289,3 +322,22 @@ def start_update():
def start_reboot():
_start(service.JOB_KIND_REBOOT)
return redirect(url_for("maintenance.overview"))
@bp.post("/command")
@permission_required("servicing_command.execute")
@reauth_required
def start_command():
"""Eine Befehlszeile auf allen ausgewaehlten Clients.
Dieselbe Schwelle wie die Konsole: eigenes Recht **und** eine frische
Passwortbestaetigung. Wer hier etwas eintippt, tut es auf einem Dutzend
Maschinen gleichzeitig, und niemand sieht dabei zu.
"""
try:
befehl = service.befehl_pruefen(request.form.get("befehl", ""))
except ValueError as exc:
flash(str(exc), "danger")
return redirect(url_for("maintenance.overview"))
_start(service.JOB_KIND_COMMAND, befehl=befehl)
return redirect(url_for("maintenance.overview"))
+238 -56
View File
@@ -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
@@ -50,6 +58,10 @@ VERBINDUNGSLIMIT = 6.0
#: bis ein Schub weitergereicht wird.
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:
device = inventory.get_device(conn, device_id)
@@ -58,6 +70,30 @@ def _geraet(conn: Any, device_id: int) -> dict[str, Any] | None:
return device
def _erklaerung(fehler: OSError) -> str:
"""Die Fehlernummer in einem Satz, der weiterhilft.
"No route to host" und "Connection refused" sehen im Protokoll gleich
harmlos aus und bedeuten das Gegenteil voneinander: einmal kommt man gar
nicht hin, einmal kommt man hin und niemand hoert zu. Und beides hat mit
einem gelungenen Ping nichts zu tun -- der prueft eine andere Schicht.
"""
import errno as fehlernummern
nummer = getattr(fehler, "errno", None)
if nummer == fehlernummern.EHOSTUNREACH:
return (
f"{fehler} – kein Weg dorthin. Eine Firewall oder eine Route steht "
"dazwischen; ein gelungener Ping sagt darüber nichts, der prüft eine "
"andere Schicht"
)
if nummer == fehlernummern.ECONNREFUSED:
return f"{fehler} – das Gerät ist erreichbar, aber auf diesem Port hört niemand"
if nummer == fehlernummern.ETIMEDOUT:
return f"{fehler} – keine Antwort; Paketfilter verwerfen meist stillschweigend"
return str(fehler)
def _port(conn: Any) -> int:
extension = core()
if not extension.settings.get(conn, "vnc_enabled"):
@@ -88,42 +124,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/<int:device_id>/screen/probe")
@permission_required("device_vnc.execute")
@reauth_required
@@ -161,6 +206,73 @@ def probe(device_id: int):
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]
"""Meldet Seite und -- falls verfuegbar -- die WebSocket-Strecke an."""
app.register_blueprint(bp)
@@ -226,42 +338,116 @@ def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def]
ws.close(1008, "Für diesen Client ist kein Ziel bekannt.")
return
try:
gegenstelle = socket.create_connection((host, port), timeout=VERBINDUNGSLIMIT)
except OSError as exc:
ws.close(1011, f"{host}:{port} antwortet nicht ({exc}).")
gegenstelle = None
ausweich = str(device.get("ip") or "").strip()
for versuch, ziel_host in enumerate([host] + ([ausweich] if ausweich != host else [])):
try:
gegenstelle = socket.create_connection((ziel_host, port), timeout=VERBINDUNGSLIMIT)
if versuch:
# Der Name fuehrte ins Leere, die Adresse nicht. Das ist eine
# Auskunft ueber das Netz, keine Nebensaechlichkeit: irgendwo
# zeigt ein Eintrag woandershin.
if live_log := extension.registry.get("live_log"):
live_log.warning(
"vnc",
f"{host} war nicht erreichbar – Sitzung läuft über {ziel_host}",
)
host = ziel_host
break
except OSError as exc:
letzter = exc
if gegenstelle is None:
ws.close(1011, f"{host}:{port} antwortet nicht ({_erklaerung(letzter)}).")
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.
# 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)
# Ins **laufende** Protokoll, nicht in die Aenderungskette: eine
# Bildschirmsitzung aendert nichts an der Anlage. Das Aenderungsprotokoll
# soll beantworten, wer was umgestellt hat -- wer wann zugesehen hat,
# gehoert nicht in dieselbe Liste und macht sie in einem Betrieb mit
# ein paar Dutzend Geraeten unlesbar. Sichtbar ist die Sitzung trotzdem,
# solange sie laeuft.
begonnen = time.monotonic()
with extension.database.transaction(conn):
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="device.screen_opened",
target=str(device["name"]),
detail=f"Bildschirmsitzung zu {host}:{port}",
ip=client_ip(),
severity="warning",
)
live = extension.registry.get("live_log")
if live is not None:
live.info("vnc", f"{current_user.username} öffnet den Bildschirm von {device['name']}")
laeuft = threading.Event()
laeuft.set()
#: 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:
"""Alles, was der VNC-Server schickt, geht als Binaerrahmen weiter."""
try:
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 gehoert[0]:
schluss[0] = (
f"{host}:{port} hat die Verbindung sofort wieder geschlossen."
)
break
gehoert[0] = True
zuletzt[0] = time.monotonic()
ws.send(daten)
except Exception: # noqa: BLE001 - Abbruch ist der Normalfall, nicht die Stoerung
pass
finally:
laeuft.clear()
try:
ws.close()
if schluss[0]:
ws.close(1000 if gehoert[0] else 1011, schluss[0])
else:
ws.close()
except Exception: # noqa: BLE001
pass
@@ -278,6 +464,7 @@ def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def]
# waere entweder ein Irrtum oder ein Versuch -- beides endet
# hier und nicht auf dem Geraet.
continue
zuletzt[0] = time.monotonic()
gegenstelle.sendall(nachricht)
except Exception: # noqa: BLE001
logger.debug("Bildschirmsitzung beendet (%s)", device["name"], exc_info=True)
@@ -289,14 +476,9 @@ def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def]
pass
gegenstelle.close()
dauer = int(time.monotonic() - begonnen)
with extension.database.session() as frisch, extension.database.transaction(frisch):
audit.log(
frisch,
actor=current_user.username,
actor_id=current_user.id,
action="device.screen_closed",
target=str(device["name"]),
detail=f"Dauer {dauer}s",
ip=client_ip(),
severity="info",
if live is not None:
live.info(
"vnc",
f"Bildschirmsitzung {device['name']} beendet ({dauer}s)"
+ (f" – {schluss[0]}" if schluss[0] else ""),
)
@@ -105,6 +105,7 @@ def system():
"dashboard_public",
"vnc_enabled",
"vnc_port",
"vnc_idle_timeout",
"vnc_warn_unencrypted_off",
"terminal_switches_enabled",
"terminal_clients_enabled",
@@ -148,6 +149,7 @@ def system_save():
"dashboard_public",
"vnc_enabled",
"vnc_port",
"vnc_idle_timeout",
"vnc_warn_unencrypted_off",
"terminal_switches_enabled",
"terminal_clients_enabled",
+10
View File
@@ -76,6 +76,16 @@ CLIENT_COMMANDS: tuple[tuple[str, str], ...] = (
("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"),
("apt list --upgradable 2>/dev/null", "Verfügbare Aktualisierungen"),
("uname -a", "Kernel und Architektur"),
# Die beiden schreiben -- deshalb stehen sie am Ende und sagen es im Namen.
# Eine Vorlage fuellt nur das Eingabefeld; abgeschickt wird von Hand.
(
'echo "Encryption=AlwaysOff" | sudo tee -a /etc/vnc/config.d/common.custom',
"VNC: Streckenverschlüsselung abschalten (schreibt)",
),
(
"sudo systemctl restart vncserver-x11-serviced",
"VNC-Dienst neu starten (schreibt)",
),
)
+20 -1
View File
@@ -187,6 +187,24 @@ SERVICING_TOOL = Resource(
action_hints={A.EXECUTE: "Installation auslösen."},
)
#: Ein freier Befehl auf vielen Geraeten ist das Maechtigste, was diese
#: Anwendung kann -- und das Einzige, dessen Wirkung sie nicht kennt. Deshalb
#: ein eigenes Recht, das niemand nebenbei bekommt: wer Pakete aktualisieren
#: oder neu starten darf, tut etwas Umrissenes; wer hier etwas eintippt, tut
#: alles, was ein Konto auf dem Geraet tun kann. Die Vorlagen nehmen dem den
#: Alltag ab, nicht die Verantwortung.
SERVICING_COMMAND = Resource(
key="servicing_command",
label="Befehl auf mehreren Geräten",
actions=(A.READ, A.EXECUTE),
hint="Eine Befehlszeile über SSH, auf allen ausgewählten Clients",
action_hints={
A.READ: "Die Karte sehen – und damit, was zuletzt lief.",
A.EXECUTE: "Befehl absenden. Verlangt eine frische Passwortbestätigung; "
"jeder Auftrag steht mit seinem Wortlaut im Änderungsprotokoll.",
},
)
#: Die Wartungsseite zeigt alle drei Aufgaben. Sie oeffnet, wer **eine** davon
#: sehen darf -- und zeigt dann nur die. Als Konstante, damit die vier Routen
#: dieselbe Antwort geben; einzeln getippt liefen sie irgendwann auseinander.
@@ -194,6 +212,7 @@ SERVICING_PAGE: tuple[str, ...] = (
"servicing_update.read",
"servicing_reboot.read",
"servicing_tool.read",
"servicing_command.read",
)
#: Elf Funktionen hingen an ``dhcp.edit``: Grundeinstellungen, Subnetze,
@@ -354,7 +373,7 @@ MAINTENANCE_AREA = Area(
icon="wrench",
hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich "
"verborgen – auch wenn einzelne Rechte darunter gesetzt sind.",
resources=(SERVICING_UPDATE, SERVICING_REBOOT, SERVICING_TOOL),
resources=(SERVICING_UPDATE, SERVICING_REBOOT, SERVICING_COMMAND, SERVICING_TOOL),
)
NETWORK_AREA = Area(
+14
View File
@@ -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),
)
+2 -1
View File
@@ -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
+192 -1
View File
@@ -44,6 +44,51 @@ logger = logging.getLogger(__name__)
JOB_KIND_UPDATE = "maintenance.update"
JOB_KIND_REBOOT = "maintenance.reboot"
JOB_KIND_COMMAND = "maintenance.command"
#: Vorlagen fuer den Massenbefehl: Name, Befehl, Erklaerung.
#:
#: ``{sudo}`` wird zur Laufzeit ersetzt -- mit ``sudo -n``, wo es ohne Passwort
#: geht, sonst mit ``sudo -S`` und dem hinterlegten Passwort ueber stdin. Wer
#: eine Vorlage waehlt, sieht den Befehl im Feld und kann ihn aendern: eine
#: Vorlage ist ein Vorschlag, kein Automat.
BEFEHLSVORLAGEN: tuple[tuple[str, str, str], ...] = (
(
"VNC: Streckenverschlüsselung abschalten",
'echo "Encryption=AlwaysOff" | {sudo} tee -a /etc/vnc/config.d/common.custom',
"Danach bietet RealVNC auch RA2ne und „ohne Authentifizierung“ an – nur damit "
"kommt eine Sitzung im Browser zustande. Die Strecke ist dann unverschlüsselt; "
"im Netz zwischen Server und Gerät ist sie es ohnehin.",
),
(
"VNC-Dienst neu starten",
"{sudo} systemctl restart vncserver-x11-serviced",
"Räumt auch eine Sperre ab, die der Server nach zu vielen abgebrochenen "
"Verbindungsversuchen gesetzt hat.",
),
(
"VNC: aktuelle Einstellungen zeigen",
"cat /etc/vnc/config.d/common.custom 2>/dev/null || echo '(keine eigene Konfiguration)'",
"Liest nur – gut, um vor dem Ändern zu sehen, was schon dasteht.",
),
(
"Zeit und Zeitzone",
"timedatectl",
"Liest nur.",
),
(
"Belegung der Dateisysteme",
"df -h",
"Liest nur.",
),
)
#: Was in einer Befehlszeile nichts zu suchen hat. Nicht als Schutz vor einem
#: Menschen mit diesem Recht -- er darf ohnehin alles, was sein Konto auf dem
#: Geraet darf --, sondern gegen Unfaelle: ein Zeilenumbruch aus der
#: Zwischenablage macht aus einem Befehl unbemerkt zwei.
VERBOTENE_ZEICHEN = ("\n", "\r", "\x00")
MAX_BEFEHLSLAENGE = 500
MAX_OUTPUT_LINES = 400
CONNECT_TIMEOUT = 25
@@ -231,7 +276,10 @@ def update_job(
def list_jobs(
conn: sqlite3.Connection, *, kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT), limit: int = 60
conn: sqlite3.Connection,
*,
kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT, JOB_KIND_COMMAND),
limit: int = 60,
) -> list[dict[str, Any]]:
placeholders = ",".join("?" * len(kinds))
return Database.all(
@@ -447,6 +495,149 @@ def run_update(
)
#: So lange gilt ein Auftrag als "eben fertig geworden" und schickt seine
#: letzten Zeilen noch mit. Ohne das verschwaende die Ausgabe genau in dem
#: Augenblick, in dem sie interessant wird: wenn der Befehl durch ist.
EBEN_FERTIG_SEKUNDEN = 120
#: So viele Zeilen gehen ins Mitlesen. Genug, um zu sehen, was gerade
#: passiert; wenig genug, dass eine Abfrage alle zwei Sekunden nichts kostet.
MITLESEN_ZEILEN = 40
def letzte_zeilen(ausgabe: str, anzahl: int = MITLESEN_ZEILEN) -> str:
"""Das Ende einer Ausgabe -- das ist der Teil, der gerade entsteht."""
zeilen = [zeile for zeile in ausgabe.splitlines() if zeile.strip()]
return "\n".join(zeilen[-anzahl:])
def eben_fertig(job: dict[str, Any], sekunden: int = EBEN_FERTIG_SEKUNDEN) -> bool:
"""Ist dieser Auftrag gerade eben fertig geworden?"""
if job.get("state") in {"pending", "running"}:
return True
stempel = str(job.get("finished_at") or job.get("updated_at") or "")
if not stempel:
return False
try:
moment = datetime.fromisoformat(stempel)
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 befehl_pruefen(befehl: str) -> str:
"""Gibt den bereinigten Befehl zurueck -- oder wirft mit Klartext.
Eine eigene Funktion, weil genau diese Frage sich pruefen lassen muss,
ohne ein Geraet zu brauchen.
"""
sauber = (befehl or "").strip()
if not sauber:
raise ValueError("Es wurde kein Befehl eingegeben.")
if len(sauber) > MAX_BEFEHLSLAENGE:
raise ValueError(
f"Der Befehl ist zu lang (mehr als {MAX_BEFEHLSLAENGE} Zeichen). "
"Für längere Abläufe ist ein Skript auf dem Gerät der bessere Weg."
)
for zeichen in VERBOTENE_ZEICHEN:
if zeichen in sauber:
raise ValueError(
"Der Befehl enthält einen Zeilenumbruch. Hier läuft genau eine "
"Befehlszeile – alles danach würde stillschweigend mitlaufen."
)
return sauber
def run_command(
database: Any,
*,
job_id: str,
device: dict[str, Any],
username: str,
password: str,
befehl: str,
timeout: int,
) -> None:
"""Fuehrt **eine** Befehlszeile auf einem Geraet aus. Eigener Thread.
Bewusst ohne Deutung des Ergebnisses: was der Befehl tut, weiss der
Mensch, der ihn geschrieben hat. Festgehalten wird, was zurueckkam und mit
welchem Rueckgabewert -- mehr kann ein Werkzeug hier nicht beitragen, und
weniger waere zu wenig.
"""
name = str(device.get("name") or device.get("ip"))
def emit(line: str) -> None:
stamp = datetime.now().strftime("%H:%M:%S")
update_job(database, job_id, append_output=f"{stamp} {line}\n")
update_job(
database, job_id, state="running", progress=5, message="Verbindung wird aufgebaut"
)
try:
with database.session() as conn:
transport = open_transport(
conn,
host=str(device["ip"]),
port=int(device.get("ssh_port") or 22),
username=username,
password=password,
)
except SshError as exc:
emit(f"FEHLER: {exc}")
update_job(database, job_id, state="failed", message=str(exc), finished=True)
return
status = 1
try:
sudo, sudo_stdin = _sudo_mode(transport, password, emit)
# Nur wer {sudo} benutzt, bekommt es auch: ein Befehl ohne diese Marke
# laeuft als der angemeldete Benutzer, und das ist die richtige
# Vorgabe -- Rechte holt man sich hier ausdruecklich.
ausgefuehrt = befehl.replace("{sudo}", sudo) if "{sudo}" in befehl else befehl
emit(f"$ {ausgefuehrt}")
update_job(database, job_id, progress=40, message="Befehl läuft")
status = _run_remote_command(
transport,
ausgefuehrt,
timeout=timeout,
on_line=emit,
stdin_text=sudo_stdin if "{sudo}" in befehl else "",
)
except Exception as exc: # noqa: BLE001 - der Auftrag soll das Ergebnis tragen
emit(f"FEHLER: {exc}")
finally:
try:
transport.close()
except Exception: # noqa: BLE001
pass
emit(COMPLETION_MARKER)
update_job(
database,
job_id,
state="succeeded" if status == 0 else "failed",
progress=100,
message=(
"Befehl ausgeführt."
if status == 0
else f"Der Befehl endete mit Rückgabewert {status}."
),
finished=True,
)
with database.session() as conn, database.transaction(conn):
audit.log(
conn,
actor="system",
action="maintenance.command_finished",
target=name,
detail=f"Rückgabewert {status}",
severity="notice" if status == 0 else "warning",
)
def _reboot_windows(
database: Any,
*,
+19 -1
View File
@@ -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,
@@ -585,7 +594,16 @@ def run_sweep(
# bekannten Stand stehen. Ein Geraet, das gerade neu startet, soll
# seinen Knopf nicht verlieren.
return device, ergebnis, None
return device, ergebnis, vnc_dienst.zugang_kurz(device, port=vnc_port)
try:
return device, ergebnis, vnc_dienst.zugang_kurz(device, port=vnc_port)
except Exception: # noqa: BLE001
# Die Ueberwachung ist die Kernaufgabe, der Bildschirmzugriff eine
# Zugabe. Was hier schiefgeht -- ein Name, den der Resolver nicht
# verdaut, ein Netz, das seltsam antwortet --, darf niemals den
# ganzen Durchlauf kosten: sonst stuenden alle Geraete auf
# "unbekannt", und niemand brächte das mit VNC in Verbindung.
logger.warning("VNC-Pruefung fuer %s uebersprungen", device.get("name"), exc_info=True)
return device, ergebnis, None
with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool:
results = list(pool.map(pruefen, devices))
+7 -1
View File
@@ -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:
@@ -129,7 +132,9 @@ def aufloesbar(name: str) -> bool:
try:
socket.getaddrinfo(name, None)
ergebnis = True
except OSError:
except Exception: # noqa: BLE001
# Nicht nur OSError: ein Name mit Sonderzeichen wirft UnicodeError,
# und der ist kein OSError. Aufloesbar ist er so oder so nicht.
ergebnis = False
_AUFLOESUNG[name] = (jetzt, ergebnis)
return ergebnis
@@ -350,6 +355,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),
)
+12
View File
@@ -66,6 +66,18 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
minimum=1,
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(
"vnc_warn_unencrypted_off",
False,
@@ -102,11 +102,25 @@
<div class="tile__actions">
{% if tile.vnc %}
{# Die Sitzung laeuft in TESM selbst: ein Klick, kein
Viewer auf dem Arbeitsplatz, keine Datei. #}
<a class="btn btn--sm" href="{{ url_for('screen.screen', device_id=tile.id) }}"
title="Bildschirm im Browser öffnen">
{{ icon('monitor', size=14) }} VNC
</a>
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) }}"
title="Bildschirm im Browser öffnen">
{{ icon('monitor', size=14) }} VNC
</a>
{% endif %}
{% else %}
<span class="tile__spacer" aria-hidden="true"></span>
{% endif %}
@@ -0,0 +1,42 @@
{# 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="taste-f5" hidden
title="F5 an das Gerät senden – der Browser des Arbeitsplatzes würde sonst diese Seite neu laden">F5</button>
<button class="btn btn--sm" type="button" data-rolle="trennen" hidden>Trennen</button>
{% endmacro %}
@@ -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" %}
</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 %}
@@ -47,6 +47,48 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<form method="post" id="maintenance-form" action="{{ url_for('maintenance.start_update') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
{% if current_user.has('servicing_command.read') %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('terminal', size=17) }} Massenbefehl</div>
<div class="card__sub">
Eine Befehlszeile über SSH, auf allen unten ausgewählten Clients – jeder als
eigener Auftrag mit eigener Ausgabe. <strong>{{ '{sudo}' }}</strong> im Befehl
wird ersetzt: mit <code>sudo -n</code>, wo es ohne Passwort geht, sonst mit dem
hinterlegten Passwort über stdin. Ohne diese Marke läuft der Befehl als das
angemeldete Konto.
</div>
</div>
</div>
<div class="card__body stack">
<label class="field">
<span class="field__label">Vorlage</span>
<select class="select" id="befehlsvorlage">
<option value="">– eigene Eingabe –</option>
{% for name, befehl, erklaerung in befehlsvorlagen %}
<option value="{{ befehl }}" data-erklaerung="{{ erklaerung }}">{{ name }}</option>
{% endfor %}
</select>
<span class="field__hint" id="befehlserklaerung">
Eine Vorlage füllt nur das Feld darunter – abgeschickt wird von Hand.
</span>
</label>
<label class="field">
<span class="field__label">Befehl</span>
<input class="input mono" type="text" name="befehl" id="befehl"
maxlength="500" autocomplete="off"
placeholder='echo "Encryption=AlwaysOff" | {sudo} tee -a /etc/vnc/config.d/common.custom'
{% if not current_user.has('servicing_command.execute') %}disabled{% endif %}>
<span class="field__hint">
Genau eine Zeile. Was der Befehl bewirkt, weiß der, der ihn schreibt – TESM
führt ihn aus und hält fest, was zurückkam.
</span>
</label>
</div>
</section>
{% endif %}
<section class="card">
<div class="table-toolbar">
<label class="check">
@@ -54,6 +96,14 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<span>Alle auswählen</span>
</label>
<span class="table-toolbar__spacer"></span>
{% if current_user.has('servicing_command.execute') %}
<button class="btn btn--sm" type="submit"
formaction="{{ url_for('maintenance.start_command') }}"
data-confirm="Den eingetragenen Befehl auf allen ausgewählten Geräten ausführen? Was er bewirkt, entscheidet er selbst – TESM führt ihn nur aus."
data-confirm-title="Befehl senden" data-confirm-ok="Senden" data-confirm-style="danger">
{{ icon('terminal', size=15) }} Befehl senden
</button>
{% endif %}
{% if current_user.has('servicing_update.execute') %}
<button class="btn btn--sm btn--primary" type="submit"
formaction="{{ url_for('maintenance.start_update') }}"
@@ -95,9 +145,19 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<span class="sr-only">{{ device.name }} auswählen</span>
</label>
</td>
<td class="fw-600">{{ device.name }}</td>
<td class="fw-600">
{# Mitlesen, ohne die Seite zu verlassen -- so war es in der
Fassung 1.2.8, und es fehlte seither. Die Zeile darunter
klappt auf, sobald für dieses Gerät etwas läuft. #}
<button class="btn btn--ghost btn--sm" type="button"
data-ausgabe-schalter="{{ device.name }}"
title="Ausgabe dieses Geräts mitlesen">
{{ icon('chevron-right', size=13) }}
</button>
{{ device.name }}
</td>
<td class="mono fs-sm">{{ device.ip }}</td>
<td>
<td data-zustand="{{ device.id }}">
{% if device.state == 'online' %}{{ pill('online', 'online') }}
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
@@ -129,6 +189,11 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
{% endif %}
</td>
</tr>
<tr data-ausgabe-zeile="{{ device.name }}" hidden>
<td colspan="7" class="p-0">
<pre class="job-output" data-ausgabe="{{ device.name }}">Noch keine Ausgabe.</pre>
</td>
</tr>
{% endfor %}
</tbody>
</table>
@@ -161,13 +226,13 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<thead><tr><th>Gestartet</th><th>Gerät</th><th>Art</th><th>Zustand</th><th>Fortschritt</th><th>Meldung</th></tr></thead>
<tbody>
{% for job in jobs %}
<tr>
<tr data-auftrag="{{ job.id }}">
<td class="fs-sm nowrap">{{ job.created_at | dt }}</td>
<td class="fs-sm fw-600">
<a href="{{ url_for('maintenance.job_detail', job_id=job.id) }}">{{ job.subject }}</a>
</td>
<td class="fs-sm">{{ 'Aktualisierung' if job.kind.endswith('update') else 'Neustart' }}</td>
<td>
<td data-auftrag-zustand>
{% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }}
{% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }}
{% elif job.state == 'running' %}{{ pill('läuft', 'info') }}
@@ -175,11 +240,12 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
{% else %}{{ pill('wartet', '') }}{% endif %}
</td>
<td style="min-width:120px">
<div class="progress {{ 'progress--success' if job.state == 'succeeded' else ('progress--danger' if job.state == 'failed' else '') }}">
<div class="progress {{ 'progress--success' if job.state == 'succeeded' else ('progress--danger' if job.state == 'failed' else '') }}"
data-auftrag-balken>
<div class="progress__bar" style="width: {{ job.progress }}%"></div>
</div>
</td>
<td class="fs-xs muted">{{ job.message }}</td>
<td class="fs-xs muted" data-auftrag-meldung>{{ job.message }}</td>
</tr>
{% endfor %}
</tbody>
@@ -193,6 +259,18 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
{% block scripts %}
<script type="module" nonce="{{ csp_nonce }}">
// Eine Vorlage füllt das Feld und erklärt sich -- abgeschickt wird von Hand.
const vorlage = document.getElementById("befehlsvorlage");
const befehl = document.getElementById("befehl");
const erklaerung = document.getElementById("befehlserklaerung");
vorlage?.addEventListener("change", () => {
const gewaehlt = vorlage.selectedOptions[0];
if (vorlage.value) befehl.value = vorlage.value;
erklaerung.textContent =
gewaehlt?.dataset.erklaerung ||
"Eine Vorlage füllt nur das Feld darunter – abgeschickt wird von Hand.";
});
// Auswahl beim Laden bewusst zurücksetzen: Browser stellen den Checked-Zustand
// sonst aus dem Verlauf wieder her – und dann wird bei "Neustarten"
// versehentlich das falsche Gerät erwischt.
@@ -205,14 +283,113 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
.forEach((box) => (box.checked = all.checked));
});
// Auftragsliste nachladen, solange etwas läuft.
// Mitlesen: die Zeile unter dem Gerät zeigt, was dort gerade passiert.
//
// Vorher lud diese Seite sich alle zwei Sekunden komplett neu, solange etwas
// lief -- das verwarf jede Auswahl, jeden Tastendruck im Befehlsfeld und
// sprang nach oben. Jetzt kommen nur die letzten Zeilen, und die Seite
// bleibt stehen.
const schalter = Array.from(document.querySelectorAll("[data-ausgabe-schalter]"));
const offen = new Set();
schalter.forEach((knopf) => {
knopf.addEventListener("click", () => {
const name = knopf.dataset.ausgabeSchalter;
const zeile = document.querySelector(`[data-ausgabe-zeile="${CSS.escape(name)}"]`);
if (!zeile) return;
zeile.hidden = !zeile.hidden;
if (zeile.hidden) offen.delete(name);
else offen.add(name);
});
});
function zeigen(name, text) {
const feld = document.querySelector(`[data-ausgabe="${CSS.escape(name)}"]`);
const zeile = document.querySelector(`[data-ausgabe-zeile="${CSS.escape(name)}"]`);
if (!feld || !zeile) return;
const unten = feld.scrollTop + feld.clientHeight >= feld.scrollHeight - 24;
feld.textContent = text || "Noch keine Ausgabe.";
if (unten) feld.scrollTop = feld.scrollHeight;
// Was läuft, klappt von selbst auf -- wer zusieht, will nicht erst suchen.
if (text && zeile.hidden && !offen.has(name)) zeile.hidden = false;
}
// Zustand, Fortschritt und Meldung eines Auftrags nachziehen.
//
// Vorher lud die Seite sich dafür komplett neu, sobald der letzte Auftrag
// durch war -- und riss damit genau das weg, was man lesen wollte: die
// Ausgabe stand da, dann sprang die Seite, und danach war sie leer.
const ZUSTAENDE = {
succeeded: ["erfolgreich", "pill--online", "progress--success"],
failed: ["fehlgeschlagen", "pill--danger", "progress--danger"],
running: ["läuft", "pill--info", ""],
unknown: ["unklar", "pill--warning", ""],
pending: ["wartet", "", ""],
};
function auftragZeigen(job) {
const zeile = document.querySelector(`[data-auftrag="${CSS.escape(job.id)}"]`);
if (!zeile) return false;
const [text, pillenKlasse, balkenKlasse] = ZUSTAENDE[job.state] || ZUSTAENDE.pending;
const pille = zeile.querySelector("[data-auftrag-zustand] .pill");
if (pille && pille.textContent.trim() !== text) {
pille.textContent = text;
pille.className = `pill ${pillenKlasse}`.trim();
}
const balken = zeile.querySelector("[data-auftrag-balken]");
if (balken) {
balken.className = `progress ${balkenKlasse}`.trim();
const inner = balken.querySelector(".progress__bar");
if (inner) inner.style.width = `${job.progress || 0}%`;
}
const meldung = zeile.querySelector("[data-auftrag-meldung]");
if (meldung && meldung.textContent.trim() !== (job.message || "")) {
meldung.textContent = job.message || "";
}
return true;
}
let neueAuftraege = false;
async function poll() {
let weiter = 10000;
try {
const state = await window.tesm.api("{{ url_for('maintenance.status') }}");
if (state.running) window.setTimeout(() => location.reload(), state.poll_ms);
const state = await window.tesm.api("{{ url_for('maintenance.status') }}", { auto: true });
weiter = state.poll_ms || 10000;
state.jobs.forEach((job) => {
if (job.output) zeigen(job.subject, job.output);
// Ein Auftrag, der in der Tabelle noch gar nicht steht, ist neu --
// dafür braucht es die Seite einmal frisch, aber erst, wenn nichts
// mehr läuft.
if (!auftragZeigen(job)) neueAuftraege = true;
});
// Erreichbarkeit mitziehen -- sonst steht die Seite mit dem Stand ihres
// Aufbaus da, und wer nach einem Neustart auf die Rückkehr wartet, sieht
// "unbekannt", bis er von Hand neu lädt.
(state.devices || []).forEach((geraet) => {
const zelle = document.querySelector(`[data-zustand="${geraet.id}"]`);
if (!zelle) return;
const [text, art] =
geraet.state === "online"
? ["online", "pill--online"]
: geraet.state === "offline"
? ["offline", "pill--offline"]
: ["unbekannt", "pill--warning"];
const pille = zelle.querySelector(".pill");
if (pille && pille.textContent.trim() !== text) {
pille.textContent = text;
pille.className = `pill ${art}`;
}
});
// Neu laden nur, wenn Aufträge dazugekommen sind, die in der Tabelle
// fehlen -- und auch dann erst, wenn nichts mehr läuft. Wer zusieht,
// soll nicht mitten im Lesen weggerissen werden.
if (neueAuftraege && !state.running) {
window.setTimeout(() => location.reload(), 8000);
neueAuftraege = false;
}
} catch {
/* stumm bleiben – die Seite ist auch ohne Aktualisierung nutzbar */
}
window.setTimeout(poll, weiter);
}
poll();
</script>
+9 -35
View File
@@ -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. #}
<script type="application/json" id="screen-credentials-data">{{ vorgaben | tojson }}</script>
<div class="stack">
{% if zugang.hinderungsgrund %}
{{ alert(zugang.hinderungsgrund, 'warning') }}
@@ -49,9 +43,7 @@
</div>
</div>
<div class="row row--tight">
<button class="btn btn--sm" type="button" id="screen-cad" hidden
title="Strg+Alt+Entf an das Gerät senden">Strg+Alt+Entf</button>
<button class="btn btn--sm" type="button" id="screen-disconnect" hidden>Trennen</button>
{{ 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. #}
@@ -59,7 +51,9 @@
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">
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
</button>
</form>
@@ -70,30 +64,10 @@
</div>
</div>
<div class="card__body stack">
<div id="screen-status" class="screen__status">Wird vorbereitet …</div>
<form id="screen-credentials" class="row row--tight" hidden onsubmit="return false">
<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>
<div class="card__body stack" id="screen" data-sitzung="{{ quelle }}">
{{ vnc_status() }}
{{ vnc_zugangsdaten() }}
{{ vnc_flaeche() }}
{% if not darf_geheimnis and device.credential_id %}
<p class="fs-xs faint">
@@ -46,17 +46,6 @@
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
checked=values.dashboard_public,
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 class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form>
@@ -117,6 +106,39 @@
</form>
</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">
<div class="card__header">
<div class="flex-1">
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-core"
version = "2.8.9"
version = "2.8.14"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = [
@@ -1275,6 +1275,24 @@
color: var(--text-faint);
}
/* Mitlesen auf der Wartungsseite: die Ausgabe eines laufenden Auftrags,
direkt unter seinem Geraet. Dunkler Grund wie in jeder Konsole -- was
dort steht, kommt von einer fremden Maschine und nicht aus dieser
Oberflaeche. */
.job-output {
margin: 0;
max-height: 220px;
overflow: auto;
padding: var(--sp-3) var(--sp-4);
background: #101216;
color: #d7dbe0;
font-family: var(--font-mono);
font-size: var(--fs-xs);
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
/* ---------------------------------------------------------------------- #
Bildschirmsitzung (VNC)
@@ -1295,6 +1313,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);
@@ -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,31 +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;
// 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) {
@@ -50,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) {
@@ -63,39 +56,80 @@ 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}`;
//: Verfahren, die im Browser keinen Schluesseltausch kosten.
//:
//: 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(
document.getElementById("screen-credentials-data")?.textContent || "{}",
);
//: RA2ne. Wird es ausgehandelt, ist die Wartezeit erklaerbar und kein Fehler.
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 F5 = 0xffc2;
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, { beiEnde = null } = {}) {
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) {
@@ -110,29 +144,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
@@ -158,22 +169,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) {
if (beendet) return;
melden(versuche ? "Nächster Versuch …" : "Verbindung wird aufgebaut …");
letzterGrund = "";
letzterCode = 0;
letzterZugang = zugang;
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
// dasselbe Verfahren wieder zuerst -- und der zweite Anlauf waere eine
// Wiederholung des ersten.
// Drei Regeln, alle aus Messung und keine aus Vermutung:
//
// 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) {
if (gescheitert.has(typ)) return false;
if (bevorzugt) return typ === bevorzugt;
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
@@ -185,20 +279,27 @@ function starten(wurzel) {
rfb.focusOnClick = true;
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) {
melden("Sitzung beendet.");
// Beim sauberen Schluss steht der Grund im Schliessrahmen -- etwa,
// dass TESM wegen Untätigkeit getrennt hat.
melden(letzterGrund ? `Sitzung beendet – ${letzterGrund}` : "Sitzung beendet.");
// Im Fenster gibt es nach dem Trennen nichts mehr zu sehen: ein leerer
// Rahmen, den man wegklicken muss. Bei einem **Abbruch** bleibt es
// stehen -- dort steht der Grund, und der ist der ganze Zweck.
beiEnde?.();
} else if (!stand && naechsterVersuch()) {
return;
} else {
@@ -209,9 +310,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(
@@ -233,56 +331,138 @@ 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) => {
window.clearTimeout(aufbauUhr);
melden(
`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);
});
// F5 faengt der Browser des Arbeitsplatzes ab -- er wuerde diese Seite neu
// laden statt das Bild auf dem Geraet.
teil("taste-f5")?.addEventListener("click", () => {
if (!rfb) return;
rfb.sendKey(F5, "F5", true);
rfb.sendKey(F5, "F5", 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, {
beiEnde: () => fenster.close(),
});
});
// 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;
});
}