Compare commits

...
12 Commits
27 changed files with 1725 additions and 242 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm" name = "tesm"
version = "2.8.13" version = "2.8.25"
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 = [
+16 -1
View File
@@ -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),
} }
+20 -19
View File
@@ -374,13 +374,13 @@ def detail(device_id: int):
def _vnc_zugang(conn: Any, device: dict[str, Any]) -> vnc.Zugang | None: def _vnc_zugang(conn: Any, device: dict[str, Any]) -> vnc.Zugang | None:
"""Der Bildschirmzugriff dieses Clients -- frisch gefragt, nicht geraten. """Der Bildschirmzugriff dieses Clients -- aus dem, was bekannt ist.
Die Kacheln der Statusuebersicht nehmen den Stand aus dem letzten Diese Seite fragt das Geraet **nicht**. Erreichbarkeit kommt aus dem
Ueberwachungsdurchlauf; hier geht es um **ein** Geraet, also fragt die Seite Ueberwachungsdurchlauf, die Anmeldeverfahren aus der letzten
selbst nach (ein halbe Sekunde Zeitlimit, und nur wenn der Client gerade ausdruecklichen Pruefung. Der Grund steht in :func:`vnc.zugang_gemerkt`:
erreichbar ist). Das Ergebnis wird gleich festgehalten -- dann stimmt auch jede Messung ist eine angefangene Protokollverhandlung, und ein RealVNC
die Kachel, ohne auf den naechsten Durchlauf zu warten. Server zaehlt so etwas mit.
""" """
extension = core() extension = core()
if not current_user.has("device_vnc.execute"): if not current_user.has("device_vnc.execute"):
@@ -388,12 +388,7 @@ def _vnc_zugang(conn: Any, device: dict[str, Any]) -> vnc.Zugang | None:
if not extension.settings.get(conn, "vnc_enabled"): if not extension.settings.get(conn, "vnc_enabled"):
return None return None
port = int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT) port = int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT)
online = str(device.get("state") or "") == "online" and not device.get("deleted_at") return vnc.zugang_gemerkt(device, port=port)
zugang = vnc.zugang(device, port=port, pruefen=online)
if online:
with transaction():
monitor.merke_vnc(conn, int(device["id"]), zugang.erreichbar, zugang.ziel)
return zugang
@bp.get("/<int:device_id>/vnc") @bp.get("/<int:device_id>/vnc")
@@ -416,15 +411,21 @@ def vnc_connection(device_id: int):
ziel = vnc.ziel(device) ziel = vnc.ziel(device)
if not ziel: if not ziel:
abort(404) abort(404)
with transaction(): # Kein Eintrag in der Aenderungskette: eine Verbindungsdatei ist derselbe
_audit( # Vorgang wie die Sitzung im Browser, nur fuer einen Viewer auf dem
conn, # Arbeitsplatz -- und sie aendert nichts. Wer zusieht, steht im laufenden
"device.vnc_opened", # Protokoll; das Aenderungsprotokoll bleibt die Liste der Eingriffe.
str(device["name"]), live = core().registry.get("live_log")
f"Verbindungsdatei für {ziel}:{port}", if live is not None:
live.info(
"vnc", f"{current_user.username} lädt die Verbindungsdatei für {device['name']}"
) )
return Response( return Response(
vnc.verbindungsdatei(device, port=port), vnc.verbindungsdatei(
device,
port=port,
warnung_aus=bool(extension.settings.get(conn, "vnc_warn_unencrypted_off")),
),
mimetype="application/octet-stream", mimetype="application/octet-stream",
headers={ headers={
"Content-Disposition": f'attachment; filename="{vnc.dateiname(device)}"', "Content-Disposition": f'attachment; filename="{vnc.dateiname(device)}"',
+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 flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for
from tesm_core.security.sessions import fragment from tesm_core.security.sessions import fragment
from tesm_core import audit 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.auth.service import client_ip
from tesm_core.db import Database from tesm_core.db import Database
from tesm_core.extension import core, db, transaction 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("/") @bp.get("/")
@permission_required(*SERVICING_PAGE, mode="any") @permission_required(*SERVICING_PAGE, mode="any")
def overview(): def overview(): # noqa: C901 - eine Seite, drei Aufgaben
conn = db() conn = db()
return render_template( return render_template(
"tesm/maintenance.html", "tesm/maintenance.html",
@@ -89,6 +89,7 @@ def overview():
terminal_enabled=bool(core().settings.get(conn, "terminal_clients_enabled")), terminal_enabled=bool(core().settings.get(conn, "terminal_clients_enabled")),
windows_tool_missing=_windows_tool_missing(conn), windows_tool_missing=_windows_tool_missing(conn),
windows_package=windowsops.REQUIRED_PACKAGE, windows_package=windowsops.REQUIRED_PACKAGE,
befehlsvorlagen=service.BEFEHLSVORLAGEN,
) )
@@ -150,6 +151,13 @@ def status():
{ {
"running": running, "running": running,
"poll_ms": 2000 if running else 10000, "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": [ "jobs": [
{ {
"id": job["id"], "id": job["id"],
@@ -159,6 +167,16 @@ def status():
"progress": job["progress"], "progress": job["progress"],
"message": job["message"], "message": job["message"],
"updated_at": job["updated_at"], "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 for job in jobs
], ],
@@ -187,7 +205,7 @@ def job_output(job_id: str):
return render_template("tesm/_maintenance_output.html", job=job) return render_template("tesm/_maintenance_output.html", job=job)
def _start(kind: str) -> None: def _start(kind: str, befehl: str = "") -> None:
extension = core() extension = core()
conn = db() conn = db()
device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()] 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, actor_id=current_user.id,
action=kind, action=kind,
target=str(device["name"]), 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(), 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.start_background(
service.run_update, service.run_update,
database=extension.database, database=extension.database,
@@ -289,3 +322,22 @@ def start_update():
def start_reboot(): def start_reboot():
_start(service.JOB_KIND_REBOOT) _start(service.JOB_KIND_REBOOT)
return redirect(url_for("maintenance.overview")) 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"))
+303 -62
View File
@@ -30,7 +30,15 @@ import threading
import time import time
from typing import Any from typing import Any
from flask import Blueprint, abort, render_template from flask import (
Blueprint,
abort,
flash,
redirect,
render_template,
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
@@ -50,6 +58,10 @@ VERBINDUNGSLIMIT = 6.0
#: bis ein Schub weitergereicht wird. #: bis ein Schub weitergereicht wird.
BLOCK = 32 * 1024 BLOCK = 32 * 1024
#: Wie oft nachgesehen wird, ob die Sitzung noch etwas tut. Kurz genug, dass
#: das Trennen puenktlich ist, lang genug, dass das Nachsehen nichts kostet.
TAKT = 1.0
def _geraet(conn: Any, device_id: int) -> dict[str, Any] | None: def _geraet(conn: Any, device_id: int) -> dict[str, Any] | None:
device = inventory.get_device(conn, device_id) device = inventory.get_device(conn, device_id)
@@ -58,6 +70,30 @@ def _geraet(conn: Any, device_id: int) -> dict[str, Any] | None:
return device 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: def _port(conn: Any) -> int:
extension = core() extension = core()
if not extension.settings.get(conn, "vnc_enabled"): if not extension.settings.get(conn, "vnc_enabled"):
@@ -71,11 +107,13 @@ def _port(conn: Any) -> int:
def screen(device_id: int): def screen(device_id: int):
"""Die Seite mit der Leinwand. """Die Seite mit der Leinwand.
Sie misst vor dem Verbinden, **wie** der VNC-Server sich authentifizieren Sie beruehrt das Geraet nicht: die Sitzung selbst ist der einzige Anlass,
will. Bietet er nur Verfahren an, die der Browser-Client nicht kennt, steht dort anzuklopfen. Was ueber die Anmeldeverfahren bekannt ist, steht in der
das als Satz auf der Seite -- mit der Zeile, die das auf dem Geraet aendert. Datenbank -- aus der letzten ausdruecklichen Pruefung (Knopf "Verfahren
Der Unterschied zur schwarzen Flaeche ist der ganze Punkt: wer nicht weiss, pruefen"). Dass diese Seite frueher selbst gemessen hat, war ein Fehler:
woran es liegt, sucht den Fehler bei TESM. jede Messung ist eine angefangene und abgebrochene Protokollverhandlung,
und ein RealVNC Server zaehlt so etwas -- das Oeffnen der Seite hat der
Sitzung also selbst im Weg gestanden.
""" """
conn = db() conn = db()
device = _geraet(conn, device_id) device = _geraet(conn, device_id)
@@ -85,43 +123,156 @@ def screen(device_id: int):
if not port: if not port:
abort(404) abort(404)
zugang = vnc.zugang(device, port=port, pruefen=str(device.get("state") or "") == "online") zugang = vnc.zugang_gemerkt(device, port=port)
benutzer, passwort = "", ""
darf_geheimnis = bool(current_user.has("credentials.secrets"))
if device.get("credential_id") and darf_geheimnis:
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( return render_template(
"tesm/screen.html", "tesm/screen.html",
device=device, device=device,
zugang=zugang, zugang=zugang,
websocket_path=f"/ws/screen/{device_id}", quelle=url_for("screen.session_json", device_id=device_id),
vorgaben={"username": benutzer, "password": passwort}, darf_geheimnis=bool(current_user.has("credentials.secrets")),
darf_geheimnis=darf_geheimnis,
verfahren_name=vnc.verfahren_name, 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
def probe(device_id: int):
"""Fragt das Geraet ausdruecklich, wie es sich authentifizieren will.
Ein Knopf und keine Nebenwirkung des Seitenaufbaus: die Messung bricht eine
Protokollverhandlung ab, und RealVNC zaehlt das. Sie gehoert in die Hand
dessen, der gerade sucht -- nicht in jeden Aufruf.
"""
conn = db()
device = _geraet(conn, device_id)
if device is None:
abort(404)
port = _port(conn)
if not port:
abort(404)
from ..services import monitor
zugang = vnc.zugang(device, port=port)
with core().database.transaction(conn):
monitor.merke_vnc(conn, int(device["id"]), zugang.erreichbar, zugang.ziel)
monitor.merke_verfahren(conn, int(device["id"]), zugang.verfahren, zugang.absage)
if zugang.absage:
flash(f"Der Server antwortet: „{zugang.absage}“", "warning")
elif zugang.verfahren:
flash(
"Angebotene Anmeldeverfahren: "
+ ", ".join(vnc.verfahren_name(v) for v in zugang.verfahren),
"info",
)
else:
flash(f"{zugang.ziel}:{port} hat nicht geantwortet.", "danger")
return redirect(url_for("screen.screen", device_id=device_id))
@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)
@@ -138,6 +289,26 @@ def init_app(app) -> None: # type: ignore[no-untyped-def]
def screen_socket(ws, device_id: int): # type: ignore[no-untyped-def] def screen_socket(ws, device_id: int): # type: ignore[no-untyped-def]
_strecke(ws, device_id) _strecke(ws, device_id)
@sock.route("/ws/probe")
def probe_socket(ws): # type: ignore[no-untyped-def]
"""Sagt "da", und mehr nicht.
Wozu: bricht eine Bildschirmsitzung ab, gibt es zwei sehr verschiedene
Ursachen -- der VNC-Server mag nicht, oder die WebSocket-Verbindung
kommt gar nicht erst durch (ein Proxy zwischen Browser und TESM, der
"Upgrade" nicht weiterreicht, ist der haeufigste Fall). Von aussen
sehen beide gleich aus: ein Abbruch ohne Grund. Diese Probe trennt
sie, ohne dass jemand ein Protokoll lesen muss.
"""
if not current_user.is_authenticated:
ws.close(1008, "Nicht angemeldet.")
return
try:
ws.send("da")
ws.receive(timeout=2)
except Exception: # noqa: BLE001 - der Browser ist weg, das ist keine Stoerung
pass
def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def] def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def]
"""Bytes hin, Bytes her -- und sonst nichts. """Bytes hin, Bytes her -- und sonst nichts.
@@ -167,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.") ws.close(1008, "Für diesen Client ist kein Ziel bekannt.")
return return
try: gegenstelle = None
gegenstelle = socket.create_connection((host, port), timeout=VERBINDUNGSLIMIT) ausweich = str(device.get("ip") or "").strip()
except OSError as exc: for versuch, ziel_host in enumerate([host] + ([ausweich] if ausweich != host else [])):
ws.close(1011, f"{host}:{port} antwortet nicht ({exc}).") 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 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() begonnen = time.monotonic()
with extension.database.transaction(conn): live = extension.registry.get("live_log")
audit.log( if live is not None:
conn, live.info("vnc", f"{current_user.username} öffnet den Bildschirm von {device['name']}")
actor=current_user.username,
actor_id=current_user.id,
action="device.screen_opened",
target=str(device["name"]),
detail=f"Bildschirmsitzung zu {host}:{port}",
ip=client_ip(),
severity="warning",
)
laeuft = threading.Event() laeuft = threading.Event()
laeuft.set() laeuft.set()
#: Wann zuletzt etwas floss -- in *irgendeine* Richtung. Eine Liste, weil
#: beide Richtungen daran schreiben und eine Zuweisung in Python nicht in
#: die andere hineinlaufen kann.
zuletzt = [time.monotonic()]
#: Grund des Endes, falls wir selbst getrennt haben.
schluss = [""]
#: Kam ueberhaupt schon ein Byte vom Geraet?
gehoert = [False]
def vom_geraet() -> None: def vom_geraet() -> None:
"""Alles, was der VNC-Server schickt, geht als Binaerrahmen weiter.""" """Alles, was der VNC-Server schickt, geht als Binaerrahmen weiter."""
try: try:
while laeuft.is_set(): while laeuft.is_set():
daten = gegenstelle.recv(BLOCK) try:
daten = gegenstelle.recv(BLOCK)
except socket.timeout:
# Kein Fehler, nur Stille -- und die bedeutet zweierlei,
# je nachdem, ob die Sitzung schon stand.
#
# **Vor dem ersten Byte** ist Stille ein Befund: der Port
# nimmt an, aber es kommt nichts. Genau so verhaelt sich
# ein RealVNC Server, der die Adresse gerade aussperrt. Wer
# das nicht erfaehrt, sieht ewig "Verbindung wird
# aufgebaut" -- und sucht den Fehler bei uns.
if not gehoert[0] and time.monotonic() - begonnen > VERBINDUNGSLIMIT:
schluss[0] = (
f"{host}:{port} nimmt die Verbindung an, schickt aber nichts. "
"So verhält sich ein VNC-Server, der die Adresse gerade "
"aussperrt (RealVNC: BlacklistTimeout) – ein Neustart des "
"VNC-Dienstes auf dem Gerät räumt das ab."
)
break
# **Danach** ist Stille der Normalfall: ein Bildschirm, auf
# dem sich nichts tut, schickt nichts.
if gehoert[0] and leerlauf and time.monotonic() - zuletzt[0] > leerlauf:
schluss[0] = (
f"Sitzung nach {leerlauf} Sekunden ohne Bewegung getrennt"
)
break
continue
if not daten: if not daten:
if not gehoert[0]:
schluss[0] = (
f"{host}:{port} hat die Verbindung sofort wieder geschlossen."
)
break break
gehoert[0] = True
zuletzt[0] = time.monotonic()
ws.send(daten) ws.send(daten)
except Exception: # noqa: BLE001 - Abbruch ist der Normalfall, nicht die Stoerung except Exception: # noqa: BLE001 - Abbruch ist der Normalfall, nicht die Stoerung
pass pass
finally: finally:
laeuft.clear() laeuft.clear()
try: try:
ws.close() if schluss[0]:
ws.close(1000 if gehoert[0] else 1011, schluss[0])
else:
ws.close()
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
pass pass
@@ -219,6 +464,7 @@ def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def]
# waere entweder ein Irrtum oder ein Versuch -- beides endet # waere entweder ein Irrtum oder ein Versuch -- beides endet
# hier und nicht auf dem Geraet. # hier und nicht auf dem Geraet.
continue continue
zuletzt[0] = time.monotonic()
gegenstelle.sendall(nachricht) gegenstelle.sendall(nachricht)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
logger.debug("Bildschirmsitzung beendet (%s)", device["name"], exc_info=True) logger.debug("Bildschirmsitzung beendet (%s)", device["name"], exc_info=True)
@@ -230,14 +476,9 @@ def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def]
pass pass
gegenstelle.close() gegenstelle.close()
dauer = int(time.monotonic() - begonnen) dauer = int(time.monotonic() - begonnen)
with extension.database.session() as frisch, extension.database.transaction(frisch): if live is not None:
audit.log( live.info(
frisch, "vnc",
actor=current_user.username, f"Bildschirmsitzung {device['name']} beendet ({dauer}s)"
actor_id=current_user.id, + (f" – {schluss[0]}" if schluss[0] else ""),
action="device.screen_closed",
target=str(device["name"]),
detail=f"Dauer {dauer}s",
ip=client_ip(),
severity="info",
) )
@@ -105,6 +105,8 @@ def system():
"dashboard_public", "dashboard_public",
"vnc_enabled", "vnc_enabled",
"vnc_port", "vnc_port",
"vnc_idle_timeout",
"vnc_warn_unencrypted_off",
"terminal_switches_enabled", "terminal_switches_enabled",
"terminal_clients_enabled", "terminal_clients_enabled",
"terminal_idle_timeout", "terminal_idle_timeout",
@@ -147,6 +149,8 @@ def system_save():
"dashboard_public", "dashboard_public",
"vnc_enabled", "vnc_enabled",
"vnc_port", "vnc_port",
"vnc_idle_timeout",
"vnc_warn_unencrypted_off",
"terminal_switches_enabled", "terminal_switches_enabled",
"terminal_clients_enabled", "terminal_clients_enabled",
"terminal_idle_timeout", "terminal_idle_timeout",
+10
View File
@@ -76,6 +76,16 @@ CLIENT_COMMANDS: tuple[tuple[str, str], ...] = (
("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"), ("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"),
("apt list --upgradable 2>/dev/null", "Verfügbare Aktualisierungen"), ("apt list --upgradable 2>/dev/null", "Verfügbare Aktualisierungen"),
("uname -a", "Kernel und Architektur"), ("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."}, 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 #: Die Wartungsseite zeigt alle drei Aufgaben. Sie oeffnet, wer **eine** davon
#: sehen darf -- und zeigt dann nur die. Als Konstante, damit die vier Routen #: sehen darf -- und zeigt dann nur die. Als Konstante, damit die vier Routen
#: dieselbe Antwort geben; einzeln getippt liefen sie irgendwann auseinander. #: dieselbe Antwort geben; einzeln getippt liefen sie irgendwann auseinander.
@@ -194,6 +212,7 @@ SERVICING_PAGE: tuple[str, ...] = (
"servicing_update.read", "servicing_update.read",
"servicing_reboot.read", "servicing_reboot.read",
"servicing_tool.read", "servicing_tool.read",
"servicing_command.read",
) )
#: Elf Funktionen hingen an ``dhcp.edit``: Grundeinstellungen, Subnetze, #: Elf Funktionen hingen an ``dhcp.edit``: Grundeinstellungen, Subnetze,
@@ -354,7 +373,7 @@ MAINTENANCE_AREA = Area(
icon="wrench", icon="wrench",
hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich " hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich "
"verborgen – auch wenn einzelne Rechte darunter gesetzt sind.", "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( NETWORK_AREA = Area(
+28
View File
@@ -526,6 +526,32 @@ ALTER TABLE device_status ADD COLUMN vnc_types TEXT NOT NULL DEFAULT '';
""" """
# Version 13 ----------------------------------------------------------------
#
# Die Absage eines VNC-Servers und der Zeitpunkt der Verfahrensmessung. Beide
# gehoeren getrennt vom Ueberwachungsdurchlauf gefuehrt: der fragt nur noch,
# ob der Port annimmt (eine abgebrochene Protokollverhandlung sperrt bei
# RealVNC die eigene Adresse aus), die Verfahren misst die Seite -- und auch
# die nur, wenn die letzte Messung alt ist.
V13 = """
ALTER TABLE device_status ADD COLUMN vnc_reason TEXT NOT NULL DEFAULT '';
ALTER TABLE device_status ADD COLUMN vnc_types_at TEXT;
"""
# 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.
@@ -571,4 +597,6 @@ TESM_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=10, name="vnc-erreichbarkeit", sql=V10), Migration(version=10, name="vnc-erreichbarkeit", sql=V10),
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=14, name="vnc-verfahren-das-traegt", sql=V14),
) )
+3 -1
View File
@@ -544,7 +544,9 @@ def list_devices(
st.last_ok_at, st.last_change_at, st.last_restart_at, st.restart_count, st.last_ok_at, st.last_change_at, st.last_restart_at, st.restart_count,
st.fail_count, COALESCE(st.vnc_open, 0) AS vnc_open, st.vnc_checked_at, 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_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
+192 -1
View File
@@ -44,6 +44,51 @@ logger = logging.getLogger(__name__)
JOB_KIND_UPDATE = "maintenance.update" JOB_KIND_UPDATE = "maintenance.update"
JOB_KIND_REBOOT = "maintenance.reboot" 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 MAX_OUTPUT_LINES = 400
CONNECT_TIMEOUT = 25 CONNECT_TIMEOUT = 25
@@ -231,7 +276,10 @@ def update_job(
def list_jobs( 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]]: ) -> list[dict[str, Any]]:
placeholders = ",".join("?" * len(kinds)) placeholders = ",".join("?" * len(kinds))
return Database.all( 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( def _reboot_windows(
database: Any, database: Any,
*, *,
+46 -24
View File
@@ -507,30 +507,45 @@ class SweepReport:
duration_ms: int = 0 duration_ms: int = 0
def merke_vnc( def merke_vnc(conn: sqlite3.Connection, device_id: int, offen: bool, ziel: str = "") -> None:
conn: sqlite3.Connection,
device_id: int,
offen: bool,
ziel: str = "",
verfahren: tuple[int, ...] = (),
) -> None:
"""Haelt fest, ob dort ein VNC-Server antwortet -- und unter welchem Ziel. """Haelt fest, ob dort ein VNC-Server antwortet -- und unter welchem Ziel.
Das Ziel gehoert dazu, weil die Statusuebersicht die Verbindung unmittelbar Das Ziel gehoert dazu, weil die Statusuebersicht die Verbindung unmittelbar
verlinkt. Es aus dem Namen neu herzuleiten hiesse, je Kachel und verlinkt. Es aus dem Namen neu herzuleiten hiesse, je Kachel und
Seitenaufbau eine Namensaufloesung zu versuchen; hier steht es einmal Seitenaufbau eine Namensaufloesung zu versuchen; hier steht es einmal
geschrieben, von dem, der ohnehin gefragt hat. geschrieben, von dem, der ohnehin gefragt hat.
Die Anmeldeverfahren bleiben unberuehrt: sie kommen aus einer anderen
Quelle (:func:`merke_verfahren`) und haetten hier nur den Wert "nicht
gefragt" -- der wuerde eine gute Messung ueberschreiben.
""" """
conn.execute( conn.execute(
"UPDATE device_status SET vnc_open=?, vnc_checked_at=?, vnc_target=?, vnc_types=? " "UPDATE device_status SET vnc_open=?, vnc_checked_at=?, vnc_target=? WHERE device_id=?",
"WHERE device_id=?", (1 if offen else 0, _now(), ziel, device_id),
( )
1 if offen else 0,
_now(),
ziel, def merke_verfahren(
",".join(str(v) for v in verfahren), conn: sqlite3.Connection, device_id: int, verfahren: tuple[int, ...], absage: str = ""
device_id, ) -> None:
), """Haelt fest, wie sich der VNC-Server authentifizieren will.
Mit Zeitstempel, denn diese Messung kostet eine angefangene
Protokollverhandlung -- und die soll sich nicht bei jedem Seitenaufbau
wiederholen.
"""
conn.execute(
"UPDATE device_status SET vnc_types=?, vnc_reason=?, vnc_types_at=? WHERE device_id=?",
(",".join(str(v) for v in verfahren), absage, _now(), device_id),
)
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)
) )
@@ -553,6 +568,10 @@ def run_sweep(
erreichbare Geraet: nimmt dort ein VNC-Server Verbindungen an? Sie gehoert erreichbare Geraet: nimmt dort ein VNC-Server Verbindungen an? Sie gehoert
hierher und nicht in den Seitenaufbau -- einmal je Intervall statt einmal hierher und nicht in den Seitenaufbau -- einmal je Intervall statt einmal
je Aufruf, und nur fuer Geraete, die ohnehin gerade antworten. je Aufruf, und nur fuer Geraete, die ohnehin gerade antworten.
**Nur die Frage nach dem Port**, kein Wort Protokoll: ein RealVNC Server
zaehlt abgebrochene Verhandlungen und sperrt die Adresse eine Weile aus.
Welche Anmeldeverfahren er anbietet, misst die Seite, die es braucht.
""" """
from . import vnc as vnc_dienst # spaet: vnc benutzt tcp_reachable von hier from . import vnc as vnc_dienst # spaet: vnc benutzt tcp_reachable von hier
@@ -575,7 +594,16 @@ def run_sweep(
# bekannten Stand stehen. Ein Geraet, das gerade neu startet, soll # bekannten Stand stehen. Ein Geraet, das gerade neu startet, soll
# seinen Knopf nicht verlieren. # seinen Knopf nicht verlieren.
return device, ergebnis, None return device, ergebnis, None
return device, ergebnis, vnc_dienst.zugang(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: with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool:
results = list(pool.map(pruefen, devices)) results = list(pool.map(pruefen, devices))
@@ -587,13 +615,7 @@ def run_sweep(
report.checked += 1 report.checked += 1
previous, current = record_status(conn, int(device["id"]), result) previous, current = record_status(conn, int(device["id"]), result)
if vnc_offen is not None: if vnc_offen is not None:
merke_vnc( merke_vnc(conn, int(device["id"]), vnc_offen.erreichbar, vnc_offen.ziel)
conn,
int(device["id"]),
vnc_offen.erreichbar,
vnc_offen.ziel,
vnc_offen.verfahren,
)
if result.ok: if result.ok:
report.online += 1 report.online += 1
else: else:
+150 -13
View File
@@ -61,6 +61,12 @@ class Zugang:
#: gemessen). Daraus entsteht der Satz, warum eine Sitzung im Browser #: gemessen). Daraus entsteht der Satz, warum eine Sitzung im Browser
#: gehen wird oder nicht. #: gehen wird oder nicht.
verfahren: tuple[int, ...] = () verfahren: tuple[int, ...] = ()
#: Der Text, mit dem der Server die Verbindung abgelehnt hat -- er nennt
#: dann kein Verfahren, sondern eine 0 und einen Grund.
absage: str = ""
#: 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:
@@ -72,6 +78,14 @@ class Zugang:
"""Warum eine Sitzung im Browser nicht zustande kaeme -- im Klartext.""" """Warum eine Sitzung im Browser nicht zustande kaeme -- im Klartext."""
if not self.erreichbar: if not self.erreichbar:
return f"Auf {self.ziel}:{self.port} antwortet kein VNC-Server." return f"Auf {self.ziel}:{self.port} antwortet kein VNC-Server."
if self.absage:
return (
f"Der VNC-Server auf {self.ziel} weist die Verbindung ab: "
f"„{self.absage}“. Das ist seine Auskunft, nicht unsere – "
"typisch sind eine Sperre nach zu vielen Fehlversuchen "
"(RealVNC: BlacklistTimeout), eine Adressfilterung oder eine "
"belegte Sitzung."
)
if not self.verfahren: if not self.verfahren:
return "" return ""
if self.browsertauglich: if self.browsertauglich:
@@ -118,7 +132,9 @@ def aufloesbar(name: str) -> bool:
try: try:
socket.getaddrinfo(name, None) socket.getaddrinfo(name, None)
ergebnis = True 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 ergebnis = False
_AUFLOESUNG[name] = (jetzt, ergebnis) _AUFLOESUNG[name] = (jetzt, ergebnis)
return ergebnis return ergebnis
@@ -181,7 +197,36 @@ def verfahren_name(nummer: int) -> str:
return str(nummer) return str(nummer)
def sicherheitsverfahren(host: str, port: int = STANDARD_PORT) -> tuple[str, tuple[int, ...]]: def _absage(sock: Any) -> str:
"""Der Grund, den ein Server einer abgelehnten Verbindung mitgibt.
Auf eine 0 -- ob als Anzahl (ab 3.7) oder als bestimmtes Verfahren (3.3) --
folgt eine Laenge und ein Text. Genau dieser Satz ist die Auskunft, die
sonst niemand bekommt: "zu viele Fehlversuche", "Verbindung von dieser
Adresse nicht erlaubt", "kein gemeinsames Verfahren". Ohne ihn steht da
nur, dass es nicht geht.
"""
try:
roh = sock.recv(4)
if len(roh) < 4:
return ""
laenge = int.from_bytes(roh, "big")
if not 0 < laenge <= 1024:
return ""
text = b""
while len(text) < laenge:
stueck = sock.recv(laenge - len(text))
if not stueck:
break
text += stueck
return text.decode("utf-8", "replace").strip()
except OSError:
return ""
def sicherheitsverfahren(
host: str, port: int = STANDARD_PORT
) -> tuple[str, tuple[int, ...], str]:
"""Fragt den Server, **wie** er sich authentifizieren will. """Fragt den Server, **wie** er sich authentifizieren will.
Der Anfang einer VNC-Verbindung ist fuer jeden Server derselbe: er nennt Der Anfang einer VNC-Verbindung ist fuer jeden Server derselbe: er nennt
@@ -192,8 +237,12 @@ def sicherheitsverfahren(host: str, port: int = STANDARD_PORT) -> tuple[str, tup
Das ist die Angabe, an der eine Bildschirmsitzung im Browser scheitert oder Das ist die Angabe, an der eine Bildschirmsitzung im Browser scheitert oder
gelingt, und sie hier zu messen ist der Unterschied zwischen einer gelingt, und sie hier zu messen ist der Unterschied zwischen einer
schwarzen Flaeche und dem Satz "dieser Server bietet nur Verfahren an, die schwarzen Flaeche und dem Satz "dieser Server bietet nur Verfahren an, die
der Browser nicht kennt". Gibt ``(Fassung, Verfahren)`` zurueck; bei einem der Browser nicht kennt".
Fehler ``("", ())``.
Gibt ``(Fassung, Verfahren, Absage)`` zurueck. Die Absage ist der Text, den
ein Server mitschickt, wenn er die Verbindung **ablehnt** -- er meldet dann
kein Verfahren, sondern eine 0 und einen Grund. Bei einem Fehler
``("", (), "")``.
""" """
import socket import socket
@@ -202,28 +251,68 @@ def sicherheitsverfahren(host: str, port: int = STANDARD_PORT) -> tuple[str, tup
sock.settimeout(ZEITLIMIT) sock.settimeout(ZEITLIMIT)
gruss = sock.recv(12) gruss = sock.recv(12)
if not gruss.startswith(b"RFB "): if not gruss.startswith(b"RFB "):
return "", () return "", (), ""
fassung = gruss[4:11].decode("ascii", "replace") fassung = gruss[4:11].decode("ascii", "replace")
# Wir antworten mit 3.8 -- der Fassung, die auch der Browser # Wir antworten mit 3.8 -- der Fassung, die auch der Browser
# spricht. Ein Server, der mehr kann, faellt darauf zurueck und # spricht. Ein Server, der mehr kann, faellt darauf zurueck und
# nennt trotzdem seine Verfahren. # nennt trotzdem seine Verfahren.
sock.sendall(b"RFB 003.008" + bytes([10])) # Antworten wird mit *seiner* Fassung, hoechstens 3.8. Wer einem
# 3.3-Server mit 3.8 antwortet, bekommt keine Liste, sondern eine
# 32-Bit-Zahl -- und liest sie als "keine Verfahren".
eigene = "003.003" if fassung == "003.003" else "003.008"
sock.sendall(b"RFB " + eigene.encode("ascii") + bytes([10]))
if eigene == "003.003":
# RFB 3.3: der Server bestimmt allein und schickt eine
# 32-Bit-Zahl. Das ist kein Sonderfall aus der Vorzeit --
# RealVNC faellt darauf zurueck, sobald keine Anmeldung
# verlangt wird.
rohwert = b""
while len(rohwert) < 4:
stueck = sock.recv(4 - len(rohwert))
if not stueck:
break
rohwert += stueck
if len(rohwert) < 4:
return fassung, (), ""
bestimmt = int.from_bytes(rohwert, "big")
if bestimmt == 0:
return fassung, (), _absage(sock)
return fassung, (bestimmt,), ""
anzahl = sock.recv(1) anzahl = sock.recv(1)
if not anzahl: if not anzahl:
return fassung, () return fassung, (), ""
wie_viele = anzahl[0] wie_viele = anzahl[0]
if wie_viele == 0: if wie_viele == 0:
# Null heisst: der Server lehnt ab und schickt den Grund. # Null heisst: der Server lehnt ab und schickt den Grund.
return fassung, () return fassung, (), _absage(sock)
liste = b"" liste = b""
while len(liste) < wie_viele: while len(liste) < wie_viele:
stueck = sock.recv(wie_viele - len(liste)) stueck = sock.recv(wie_viele - len(liste))
if not stueck: if not stueck:
break break
liste += stueck liste += stueck
return fassung, tuple(liste) return fassung, tuple(liste), ""
except OSError: except OSError:
return "", () return "", (), ""
def zugang_kurz(device: Mapping[str, Any], *, port: int = STANDARD_PORT) -> Zugang:
"""Nur die Frage, ob dort jemand annimmt -- ohne ein Wort Protokoll.
Das ist die Fassung fuer den Ueberwachungsdurchlauf. Er laeuft alle paar
Minuten ueber jedes Geraet, und eine angefangene und sofort wieder
abgebrochene Protokollverhandlung ist nicht nur unhoeflich: ein RealVNC
Server zaehlt abgebrochene Versuche und sperrt die Adresse eine Weile aus
(``BlacklistTimeout``). Eine Ueberwachung, die ihr eigenes Ziel aussperrt,
waere ein schoener Eigentor -- also fragt sie nur, ob der Port annimmt.
Welche Anmeldeverfahren der Server kann, misst erst die Seite, die es
wirklich braucht -- und auch die nicht oefter als noetig.
"""
host = ziel(device)
return Zugang(ziel=host, port=port, erreichbar=erreichbar(host, port))
def zugang(device: Mapping[str, Any], *, port: int = STANDARD_PORT, pruefen: bool = True) -> Zugang: def zugang(device: Mapping[str, Any], *, port: int = STANDARD_PORT, pruefen: bool = True) -> Zugang:
@@ -231,17 +320,63 @@ def zugang(device: Mapping[str, Any], *, port: int = STANDARD_PORT, pruefen: boo
host = ziel(device) host = ziel(device)
if not pruefen: if not pruefen:
return Zugang(ziel=host, port=port, erreichbar=False) return Zugang(ziel=host, port=port, erreichbar=False)
fassung, verfahren = sicherheitsverfahren(host, port) fassung, verfahren, absage = sicherheitsverfahren(host, port)
return Zugang(ziel=host, port=port, erreichbar=bool(fassung), verfahren=verfahren) return Zugang(
ziel=host,
port=port,
erreichbar=bool(fassung),
verfahren=verfahren,
absage=absage,
)
def verbindungsdatei(device: Mapping[str, Any], *, port: int = STANDARD_PORT) -> str: def zugang_gemerkt(device: Mapping[str, Any], *, port: int = STANDARD_PORT) -> Zugang:
"""Der Zugang, wie er in der Datenbank steht -- **ohne** das Geraet zu fragen.
Frueher hat diese Funktion selbst gemessen, wenn die letzte Messung alt war.
Das ist gestrichen, und zwar aus Erfahrung: jede Messung ist eine
angefangene und abgebrochene Protokollverhandlung, und ein RealVNC Server
zaehlt so etwas. Wer eine Bildschirmseite oeffnet, klopfte damit zweimal an
-- einmal zum Messen, einmal fuer die Sitzung --, und das zweite Klopfen
fiel dem ersten zum Opfer.
Jetzt beruehrt das Oeffnen einer Seite das Geraet gar nicht mehr. Die
Erreichbarkeit kommt aus dem Ueberwachungsdurchlauf (ein blosser
Verbindungsaufbau), die Anmeldeverfahren aus der letzten **ausdruecklichen**
Pruefung -- der Knopf dafuer steht auf der Seite und wird gedrueckt, wenn
etwas nicht geht.
"""
gespeichert = str(device.get("vnc_types") or "")
return Zugang(
ziel=ziel(device),
port=port,
erreichbar=bool(device.get("vnc_open")),
verfahren=tuple(
int(teil) for teil in gespeichert.split(",") if teil.strip().isdigit()
),
absage=str(device.get("vnc_reason") or ""),
bewaehrt=int(device.get("vnc_method") or 0),
)
def verbindungsdatei(
device: Mapping[str, Any], *, port: int = STANDARD_PORT, warnung_aus: bool = False
) -> str:
"""Der Inhalt der ``.vnc``-Datei -- drei Zeilen, keine vierte. """Der Inhalt der ``.vnc``-Datei -- drei Zeilen, keine vierte.
``ConnMethod=tcp`` ist die direkte Verbindung (nicht ueber den Cloud-Dienst ``ConnMethod=tcp`` ist die direkte Verbindung (nicht ueber den Cloud-Dienst
von RealVNC), ``FriendlyName`` der Name, den der Viewer anzeigt, ``Host`` von RealVNC), ``FriendlyName`` der Name, den der Viewer anzeigt, ``Host``
das Ziel. Ein abweichender Port gehoert in dieser Datei an den Host, mit das Ziel. Ein abweichender Port gehoert in dieser Datei an den Host, mit
Doppelpunkt -- einen eigenen Schluessel dafuer gibt es nicht. Doppelpunkt -- einen eigenen Schluessel dafuer gibt es nicht.
``warnung_aus`` haengt eine vierte Zeile an: ``WarnUnencrypted=False``. Sie
betrifft **den Viewer**, nicht den Server -- er zeigt sonst bei jeder
Verbindung zu einem Geraet ohne Streckenverschluesselung einen roten
Hinweis, den man wegklicken muss. Wer ``Encryption=AlwaysOff`` auf dem
Geraet gesetzt hat (damit der Browser ueberhaupt verbinden kann), sieht
diesen Hinweis fortan bei jedem Start. Er bleibt in der Vorgabe stehen:
eine unverschluesselte Strecke soll man einmal bewusst hinnehmen, nicht
stillschweigend. Abschaltbar unter Verwaltung -> System.
""" """
host = ziel(device) host = ziel(device)
if port != STANDARD_PORT: if port != STANDARD_PORT:
@@ -251,6 +386,8 @@ def verbindungsdatei(device: Mapping[str, Any], *, port: int = STANDARD_PORT) ->
f"FriendlyName={str(device.get('name') or host).strip()}", f"FriendlyName={str(device.get('name') or host).strip()}",
f"Host={host}", f"Host={host}",
] ]
if warnung_aus:
zeilen.append("WarnUnencrypted=False")
return ZEILE.join(zeilen) + ZEILE return ZEILE.join(zeilen) + ZEILE
+22
View File
@@ -66,6 +66,28 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
minimum=1, minimum=1,
maximum=65535, maximum=65535,
), ),
SettingSpec(
"vnc_idle_timeout",
300,
"int",
"Bildschirmsitzung bei Untätigkeit trennen nach (Sekunden)",
hint="Gezählt wird, wenn in **keine** Richtung mehr etwas fließt: der Bildschirm "
"ändert sich nicht und es kommt keine Eingabe. Eine Sitzung, die jemand offen "
"vergisst, hält sonst die Verbindung zum Gerät. 0 schaltet die Trennung ab.",
section="monitoring",
minimum=0,
maximum=86400,
),
SettingSpec(
"vnc_warn_unencrypted_off",
False,
"bool",
"Warnung des Viewers vor unverschlüsselter Verbindung unterdrücken",
hint="Setzt WarnUnencrypted=False in die Verbindungsdatei. Betrifft nur den "
"RealVNC Viewer auf dem Arbeitsplatz, nicht die Verbindung selbst: die bleibt "
"unverschlüsselt, wenn das Gerät es so anbietet.",
section="monitoring",
),
# -- DHCP --------------------------------------------------------------- # # -- DHCP --------------------------------------------------------------- #
SettingSpec("dhcp_domain", "local", "str", "Domainname", section="dhcp"), SettingSpec("dhcp_domain", "local", "str", "Domainname", section="dhcp"),
SettingSpec( SettingSpec(
@@ -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.
<a class="btn btn--sm" href="{{ url_for('screen.screen', device_id=tile.id) }}"
title="Bildschirm im Browser öffnen"> Mit frischer Passwortbestätigung öffnet sie sich hier
{{ icon('monitor', size=14) }} VNC im Fenster -- man bleibt bei der Übersicht und sieht
</a> 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 %} {% else %}
<span class="tile__spacer" aria-hidden="true"></span> <span class="tile__spacer" aria-hidden="true"></span>
{% endif %} {% 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" %} {% 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 %}
@@ -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') }}"> <form method="post" id="maintenance-form" action="{{ url_for('maintenance.start_update') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <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"> <section class="card">
<div class="table-toolbar"> <div class="table-toolbar">
<label class="check"> <label class="check">
@@ -54,6 +96,14 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<span>Alle auswählen</span> <span>Alle auswählen</span>
</label> </label>
<span class="table-toolbar__spacer"></span> <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') %} {% if current_user.has('servicing_update.execute') %}
<button class="btn btn--sm btn--primary" type="submit" <button class="btn btn--sm btn--primary" type="submit"
formaction="{{ url_for('maintenance.start_update') }}" 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> <span class="sr-only">{{ device.name }} auswählen</span>
</label> </label>
</td> </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 class="mono fs-sm">{{ device.ip }}</td>
<td> <td data-zustand="{{ device.id }}">
{% if device.state == 'online' %}{{ pill('online', 'online') }} {% if device.state == 'online' %}{{ pill('online', 'online') }}
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }} {% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %} {% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
@@ -129,6 +189,11 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
{% endif %} {% endif %}
</td> </td>
</tr> </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 %} {% endfor %}
</tbody> </tbody>
</table> </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> <thead><tr><th>Gestartet</th><th>Gerät</th><th>Art</th><th>Zustand</th><th>Fortschritt</th><th>Meldung</th></tr></thead>
<tbody> <tbody>
{% for job in jobs %} {% for job in jobs %}
<tr> <tr data-auftrag="{{ job.id }}">
<td class="fs-sm nowrap">{{ job.created_at | dt }}</td> <td class="fs-sm nowrap">{{ job.created_at | dt }}</td>
<td class="fs-sm fw-600"> <td class="fs-sm fw-600">
<a href="{{ url_for('maintenance.job_detail', job_id=job.id) }}">{{ job.subject }}</a> <a href="{{ url_for('maintenance.job_detail', job_id=job.id) }}">{{ job.subject }}</a>
</td> </td>
<td class="fs-sm">{{ 'Aktualisierung' if job.kind.endswith('update') else 'Neustart' }}</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') }} {% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }}
{% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }} {% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }}
{% elif job.state == 'running' %}{{ pill('läuft', 'info') }} {% 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 %} {% else %}{{ pill('wartet', '') }}{% endif %}
</td> </td>
<td style="min-width:120px"> <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 class="progress__bar" style="width: {{ job.progress }}%"></div>
</div> </div>
</td> </td>
<td class="fs-xs muted">{{ job.message }}</td> <td class="fs-xs muted" data-auftrag-meldung>{{ job.message }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -193,6 +259,18 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
{% block scripts %} {% block scripts %}
<script type="module" nonce="{{ csp_nonce }}"> <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 // Auswahl beim Laden bewusst zurücksetzen: Browser stellen den Checked-Zustand
// sonst aus dem Verlauf wieder her – und dann wird bei "Neustarten" // sonst aus dem Verlauf wieder her – und dann wird bei "Neustarten"
// versehentlich das falsche Gerät erwischt. // 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)); .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() { async function poll() {
let weiter = 10000;
try { try {
const state = await window.tesm.api("{{ url_for('maintenance.status') }}"); const state = await window.tesm.api("{{ url_for('maintenance.status') }}", { auto: true });
if (state.running) window.setTimeout(() => location.reload(), state.poll_ms); 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 { } catch {
/* stumm bleiben – die Seite ist auch ohne Aktualisierung nutzbar */ /* stumm bleiben – die Seite ist auch ohne Aktualisierung nutzbar */
} }
window.setTimeout(poll, weiter);
} }
poll(); poll();
</script> </script>
+25 -35
View File
@@ -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') }}
@@ -30,7 +24,8 @@
<div class="flex-1"> <div class="flex-1">
<div class="card__title"> <div class="card__title">
{{ icon('monitor', size=17) }} {{ device.name }} {{ icon('monitor', size=17) }} {{ device.name }}
{% if zugang.verfahren %} {% if zugang.absage %}{{ pill('Verbindung abgelehnt', 'warning') }}
{% elif zugang.verfahren %}
{% if zugang.browsertauglich %}{{ pill('Browser-Sitzung möglich', 'online') }} {% if zugang.browsertauglich %}{{ pill('Browser-Sitzung möglich', 'online') }}
{% else %}{{ pill('Verfahren nicht unterstützt', 'warning') }}{% endif %} {% else %}{{ pill('Verfahren nicht unterstützt', 'warning') }}{% endif %}
{% endif %} {% endif %}
@@ -39,15 +34,29 @@
Tastatur und Maus gehen an das Gerät, sobald das Bild steht. Die Zwischenablage Tastatur und Maus gehen an das Gerät, sobald das Bild steht. Die Zwischenablage
bleibt getrennt. bleibt getrennt.
{% if zugang.verfahren %} {% if zugang.verfahren %}
Angebotene Anmeldeverfahren: Zuletzt angebotene Anmeldeverfahren:
{% for nummer in zugang.verfahren %}{{ verfahren_name(nummer) }}{% if not loop.last %}, {% endif %}{% endfor %}. {% for nummer in zugang.verfahren %}{{ verfahren_name(nummer) }}{% if not loop.last %}, {% endif %}{% endfor %}.
{% else %}
Welche Anmeldeverfahren das Gerät anbietet, ist nicht bekannt – die Seite
fragt es nicht von sich aus.
{% endif %} {% endif %}
</div> </div>
</div> </div>
<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> {# Ausdrücklich ein Knopf und keine Nebenwirkung des Seitenaufbaus: die
<button class="btn btn--sm" type="button" id="screen-disconnect" hidden>Trennen</button> Prüfung bricht eine Protokollverhandlung ab, und RealVNC zählt das.
Wer sucht, drückt ihn; wer nur arbeiten will, merkt nichts davon. #}
<form method="post" data-no-guard
action="{{ url_for('screen.probe', device_id=device.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost" type="submit"
title="Fragt das Gerät, welche Anmeldeverfahren es anbietet. 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>
<a class="btn btn--sm btn--ghost" <a class="btn btn--sm btn--ghost"
href="{{ url_for('devices.vnc_connection', device_id=device.id) }}"> href="{{ url_for('devices.vnc_connection', device_id=device.id) }}">
{{ icon('download', size=14) }} Verbindungsdatei {{ icon('download', size=14) }} Verbindungsdatei
@@ -55,29 +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-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">
@@ -46,13 +46,6 @@
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar', {{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
checked=values.dashboard_public, checked=values.dashboard_public,
hint_text=specs['dashboard_public'].hint) }} hint_text=specs['dashboard_public'].hint) }}
{{ checkbox('vnc_enabled', 'Bildschirmzugriff (VNC) anbieten',
checked=values.vnc_enabled,
hint_text=specs['vnc_enabled'].hint) }}
<div class="form-grid">
{{ field('vnc_port', 'VNC-Port', value=values.vnc_port, type='number',
hint_text=specs['vnc_port'].hint) }}
</div>
</div> </div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div> <div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form> </form>
@@ -113,6 +106,39 @@
</form> </form>
</section> </section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('monitor', size=17) }} Bildschirm (VNC)</div>
<div class="card__sub">
Dieselben Regeln wie bei der Konsole: eigenes Recht, frische
Passwortbestätigung, getrennte Zwischenablage. Der Knopf erscheint nur, wo
ein VNC-Server antwortet – das prüft der Überwachungsdurchlauf mit.
</div>
</div>
</div>
<form method="post" action="{{ url_for('settings.system_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{{ checkbox('vnc_enabled', 'Bildschirmzugriff (VNC) anbieten',
checked=values.vnc_enabled,
hint_text=specs['vnc_enabled'].hint) }}
<div class="form-grid">
{{ field('vnc_port', 'VNC-Port', value=values.vnc_port, type='number',
hint_text=specs['vnc_port'].hint) }}
{{ field('vnc_idle_timeout', 'Trennen bei Untätigkeit (Sekunden)',
value=values.vnc_idle_timeout, type='number',
hint_text=specs['vnc_idle_timeout'].hint) }}
</div>
{{ checkbox('vnc_warn_unencrypted_off',
'Warnung des Viewers vor unverschlüsselter Verbindung unterdrücken',
checked=values.vnc_warn_unencrypted_off,
hint_text=specs['vnc_warn_unencrypted_off'].hint) }}
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form>
</section>
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
+54
View File
@@ -39,6 +39,7 @@ HTTPS=0
HTTPS_PORT="" HTTPS_PORT=""
DOMAIN="" DOMAIN=""
ACME_EMAIL="" ACME_EMAIL=""
ERZWINGEN=0
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
@@ -49,6 +50,39 @@ warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; } fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
info() { printf ' %s\n' "$*"; } info() { printf ' %s\n' "$*"; }
# -- Welche Fassung liegt hier, und welche laeuft schon? ---------------------
#
# Ein Installationslauf auf dieselbe Fassung macht nichts besser: er baut die
# Python-Umgebung neu, kopiert dieselben Dateien, laedt nginx neu und startet
# den Dienst -- mit der einzigen Wirkung, dass die Anwendung ein paar Sekunden
# weg ist. Wer "update.sh" in einen Zeitplan haengt, bekaeme das taeglich.
#
# Verglichen wird die Anwendung **und** der Kern: die beiden Nummern laufen
# absichtlich auseinander (siehe pyproject.toml), und eine Auslieferung kann
# denselben Anwendungsstand mit neuem Kern enthalten.
#: Die Nummer aus einer pyproject.toml. Die erste Zeile "version = ..." gilt --
#: weitere Vorkommen stehen in Abhaengigkeiten und gehen uns nichts an.
paket_version() {
sed -n 's/^version = "\([^"]*\)"/\1/p' "$1" 2>/dev/null | head -1
}
#: Was in dieser Installation laeuft: "<anwendung> <kern>", sonst leer.
laufende_version() {
local wurzel="$1" modul="$2"
[[ -x "$wurzel/venv/bin/python" ]] || return 0
"$wurzel/venv/bin/python" -c \
"import $modul, tesm_core; print($modul.__version__, tesm_core.__version__)" \
2>/dev/null || true
}
#: Ist das, was hier liegt, schon installiert? Eine eigene Funktion, weil genau
#: diese Frage sich pruefen lassen muss -- ohne einen Server aufzusetzen.
gleiche_version() {
local laeuft="$1" liegt="$2"
[[ -n "$laeuft" && "$laeuft" == "$liegt" ]]
}
#: Merker fuer die EXIT-Falle. Zurueckgerollt wird nur, was auch gesichert ist. #: Merker fuer die EXIT-Falle. Zurueckgerollt wird nur, was auch gesichert ist.
SICHERUNG_STEHT=0 SICHERUNG_STEHT=0
INSTALL_FERTIG=0 INSTALL_FERTIG=0
@@ -129,6 +163,7 @@ Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
--acme-email <a> Kontaktadresse fuer Let's Encrypt. --acme-email <a> Kontaktadresse fuer Let's Encrypt.
--skip-packages Keine Systempakete installieren --skip-packages Keine Systempakete installieren
--yes Rueckfragen ueberspringen --yes Rueckfragen ueberspringen
--force Auch dann einrichten, wenn dieselbe Fassung schon laeuft
USAGE USAGE
} }
@@ -144,6 +179,7 @@ while [[ $# -gt 0 ]]; do
--acme-email) ACME_EMAIL="${2:?}"; shift 2 ;; --acme-email) ACME_EMAIL="${2:?}"; shift 2 ;;
--skip-packages) SKIP_PACKAGES=1; shift ;; --skip-packages) SKIP_PACKAGES=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;; --yes|-y) ASSUME_YES=1; shift ;;
--force) ERZWINGEN=1; shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) fail "unbekannte Option: $1" ;; *) fail "unbekannte Option: $1" ;;
esac esac
@@ -261,6 +297,23 @@ FAILED_DIR="/srv/$NAME-failed-$TIMESTAMP"
IS_UPDATE=0 IS_UPDATE=0
[[ -f "$ROOT/data/app.db" ]] && IS_UPDATE=1 [[ -f "$ROOT/data/app.db" ]] && IS_UPDATE=1
# Laeuft hier schon genau das, was hier liegt? Dann ist Schluss, bevor
# irgendetwas angefasst wird -- und zwar mit Ausstiegscode 0: es ist nichts
# schiefgegangen, es war nur nichts zu tun.
MODUL="tesm"
[[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
LIEGT="$(paket_version "$REPO_DIR/apps/$APP/pyproject.toml") $(
paket_version "$REPO_DIR/packages/tesm-core/pyproject.toml")"
LAEUFT="$(laufende_version "$ROOT" "$MODUL")"
if [[ $IS_UPDATE -eq 1 && $ERZWINGEN -eq 0 ]] && gleiche_version "$LAEUFT" "$LIEGT"; then
step "Nichts zu tun"
info "$APP ${LIEGT%% *} (tesm-core ${LIEGT##* }) laeuft hier bereits."
info "Ein zweiter Durchlauf wuerde nur die Umgebung neu bauen und den"
info "Dienst neu starten -- ohne dass sich etwas aendert."
info "Trotzdem neu einrichten: --force"
exit 0
fi
step "TESM-Installation" step "TESM-Installation"
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}" info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
info "Ziel : $ROOT" info "Ziel : $ROOT"
@@ -278,6 +331,7 @@ else
info "nginx : Port $HTTP_PORT (nur HTTP)" info "nginx : Port $HTTP_PORT (nur HTTP)"
fi fi
info "Modus : $([[ $IS_UPDATE -eq 1 ]] && echo Aktualisierung || echo Neuinstallation)" info "Modus : $([[ $IS_UPDATE -eq 1 ]] && echo Aktualisierung || echo Neuinstallation)"
info "Fassung : ${LIEGT%% *} (tesm-core ${LIEGT##* })${LAEUFT:+, bisher ${LAEUFT%% *}}"
if [[ $ASSUME_YES -eq 0 ]]; then if [[ $ASSUME_YES -eq 0 ]]; then
read -r -p "Fortfahren? [j/N] " answer read -r -p "Fortfahren? [j/N] " answer
+28
View File
@@ -33,6 +33,8 @@ EXTRA=("$@")
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2 echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
echo "Beispiel : update.sh tesm latest --instance test" >&2 echo "Beispiel : update.sh tesm latest --instance test" >&2
echo "Ohne Rueckfrage: --yes anhaengen (ohne Terminal zwingend)." >&2 echo "Ohne Rueckfrage: --yes anhaengen (ohne Terminal zwingend)." >&2
echo "Laeuft die Fassung schon, endet der Aufruf ohne Arbeit; --force setzt" >&2
echo "sie trotzdem neu auf." >&2
exit 2 exit 2
} }
@@ -173,6 +175,32 @@ release_holen() {
release_holen release_holen
# Schon installiert? Dann gar nicht erst fragen.
#
# Dieselbe Pruefung steht in install.sh und gilt dort endgueltig (sie
# vergleicht auch den Kern). Hier steht sie noch einmal, weil ein Update, das
# ohne Wirkung bleibt, gar nicht erst nach einer Bestaetigung fragen soll --
# und weil "es lief durch" sonst wie eine Aktualisierung aussieht.
ERZWINGEN=0
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
[[ "${EXTRA[i]}" == "--force" ]] && ERZWINGEN=1
done
INSTALLATION="$APP"
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
[[ "${EXTRA[i]}" == "--instance" ]] && INSTALLATION="$APP-${EXTRA[i + 1]:-}"
done
MODUL="tesm"
[[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
if [[ $ERZWINGEN -eq 0 && -x "/srv/$INSTALLATION/venv/bin/python" ]]; then
LAEUFT="$("/srv/$INSTALLATION/venv/bin/python" -c \
"import $MODUL; print($MODUL.__version__)" 2>/dev/null || true)"
if [[ -n "$LAEUFT" && "v$LAEUFT" == "$TAG" ]]; then
echo "==> $APP $LAEUFT laeuft hier bereits -- nichts zu tun."
echo " Trotzdem neu einrichten: --force"
exit 0
fi
fi
# --yes darf auch hier stehen, nicht nur bei install.sh und upgrade.sh. Es # --yes darf auch hier stehen, nicht nur bei install.sh und upgrade.sh. Es
# bleibt in EXTRA: install.sh bekommt es ohnehin, doppelt schadet nicht. # bleibt in EXTRA: install.sh bekommt es ohnehin, doppelt schadet nicht.
OHNE_RUECKFRAGE=0 OHNE_RUECKFRAGE=0
+8
View File
@@ -120,6 +120,14 @@ sudo bash /srv/tesm/deploy/update.sh tesm
sudo bash /srv/tesm-license/deploy/update.sh tesm-license sudo bash /srv/tesm-license/deploy/update.sh tesm-license
``` ```
**Läuft die Fassung schon, passiert nichts.** `update.sh` vergleicht den
aufgelösten Tag mit dem, was installiert ist, und endet mit `0`, bevor es eine
Rückfrage stellt; `install.sh` prüft es noch einmal genauer (Anwendung **und**
Kern) und bricht vor dem ersten Eingriff ab. Ein zweiter Lauf auf dieselbe
Fassung baut sonst die Python-Umgebung neu, kopiert dieselben Dateien und
startet den Dienst neu -- ein paar Sekunden Ausfall ohne jede Wirkung. Wer das
trotzdem will (eine beschädigte Installation etwa), hängt `--force` an.
Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg, Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
eine **benannte Instanz** zu aktualisieren: eine **benannte Instanz** zu aktualisieren:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" name = "tesm-core"
version = "2.8.5" version = "2.8.14"
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 = [
@@ -1275,6 +1275,24 @@
color: var(--text-faint); 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) Bildschirmsitzung (VNC)
@@ -1295,6 +1313,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,42 +8,266 @@
// 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";
const wurzel = document.getElementById("screen"); // Drei Eingriffe in private Methoden der mitgelieferten Bibliothek. Bewusst
if (wurzel) { // hier und nicht in der Bibliothek selbst: eine neue Fassung von noVNC laesst
starten(wurzel); // sich dann austauschen, ohne eine Aenderung nachzupflegen -- faellt eine
} // Methode weg, entfaellt nur die Auskunft, nicht die Sitzung.
//
// Zwei davon holen nur Auskunft (_fail, _socketClose). Der dritte greift in
// die Wahl des Sicherheitsverfahrens ein -- aber erst, **nachdem** eines
// nachweislich nicht getragen hat, 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:
// ein Befund aus einer vorherigen Sitzung.
const istUnterstuetzt = RFB.prototype._isSupportedSecurityType;
function starten(wurzel) { //: Den *Grund* eines Fehlschlags kennt nur ``_fail`` -- das Ereignis
const leinwand = document.getElementById("screen-canvas"); //: "disconnect" traegt ihn nicht, und der Weg ueber die Protokollausgabe
const zeile = document.getElementById("screen-status"); //: funktioniert nicht: die Bibliothek bindet ``console.error`` beim Laden,
const abfrage = document.getElementById("screen-credentials"); //: also bevor eine Seite etwas daran aendern koennte.
const feldBenutzer = document.getElementById("screen-username"); let letzterGrund = "";
const feldPasswort = document.getElementById("screen-password"); const originalFail = RFB.prototype._fail;
const knopfSenden = document.getElementById("screen-send"); RFB.prototype._fail = function (details) {
const knopfTrennen = document.getElementById("screen-disconnect"); letzterGrund = String(details);
const knopfStrg = document.getElementById("screen-cad"); return originalFail.call(this, details);
};
const pfad = wurzel.dataset.socket; //: Und der Abschluss der Leitung selbst: der Schliesscode sagt, ob TESM
const schema = window.location.protocol === "https:" ? "wss:" : "ws:"; //: abgewiesen hat (1008), ob es ein Serverfehler war (1011) -- oder ob die
const adresse = `${schema}//${window.location.host}${pfad}`; //: Verbindung nie zustande kam (1006).
let letzterCode = 0;
const originalSocketClose = RFB.prototype._socketClose;
RFB.prototype._socketClose = function (ereignis) {
letzterCode = (ereignis && ereignis.code) || 0;
if (ereignis && ereignis.reason) {
letzterGrund = letzterGrund || String(ereignis.reason);
}
return originalSocketClose.call(this, ereignis);
};
const vorgabe = JSON.parse( //: Hoechstens ein zweiter Anlauf. Jeder Versuch ist eine Verbindung, und ein
document.getElementById("screen-credentials-data")?.textContent || "{}", //: RealVNC Server zaehlt abgebrochene Versuche mit -- wer dreimal anklopft,
); //: sperrt sich aus.
const HOECHSTENS = 2;
//: So lange darf "wird aufgebaut" stehen, bevor die Seite selbst nachsieht.
//: 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;
//: 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]);
//: 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 rfb = null;
let aufbauUhr = 0;
let versuche = 0;
let stand = false;
let daten = null;
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() {
const teile = [];
if (letzterGrund) {
teile.push(letzterGrund.replace(/^Failed (when|while) \w+: /, ""));
}
if (rfb && rfb._rfbAuthScheme > 0) {
teile.push(`gewähltes Verfahren: ${rfb._rfbAuthScheme}`);
}
if (gescheitert.size) {
teile.push(`erfolglos: ${[...gescheitert].join(", ")}`);
}
return teile.join(" · ");
}
// Steht die WebSocket-Strecke zu TESM überhaupt? Zwei sehr verschiedene
// Ursachen sehen von aussen gleich aus: der VNC-Server mag nicht, oder die
// Verbindung kommt nie zustande, weil ein Proxy zwischen Browser und TESM
// "Upgrade" nicht durchreicht. Diese Probe trennt sie.
function streckeTesten() {
return new Promise((fertig) => {
let beantwortet = false;
const pruefling = new WebSocket(`${schema}//${window.location.host}/ws/probe`);
const schliessen = (ergebnis) => {
if (beantwortet) return;
beantwortet = true;
try {
pruefling.close();
} catch (fehler) {
/* schon zu */
}
fertig(ergebnis);
};
pruefling.onmessage = () => schliessen(true);
pruefling.onerror = () => schliessen(false);
pruefling.onclose = () => schliessen(false);
window.setTimeout(() => schliessen(false), 4000);
});
}
// 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) {
melden("Verbindung wird aufgebaut …"); if (beendet) return;
rfb = new RFB(leinwand, adresse, { 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);
// 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, `${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
@@ -55,80 +279,190 @@ function starten(wurzel) {
rfb.focusOnClick = true; rfb.focusOnClick = true;
rfb.addEventListener("connect", () => { rfb.addEventListener("connect", () => {
melden(`Verbunden mit ${wurzel.dataset.ziel}`, "ok"); window.clearTimeout(aufbauUhr);
abfrage.hidden = true; stand = true;
verfahrenMelden(rfb && rfb._rfbAuthScheme > 0 ? rfb._rfbAuthScheme : 0);
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) {
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 { } else {
const erklaerung = grund();
melden( melden(
"Verbindung abgebrochen. Antwortet der VNC-Server noch, und lässt der " + erklaerung
"Arbeitsplatz die Verbindung zu?", ? `Verbindung abgebrochen – ${erklaerung}`
: "Verbindung abgebrochen. Wird geprüft, woran es liegt …",
"fehler", "fehler",
); );
if (letzterCode === 1006 || !erklaerung) {
streckeTesten().then((steht) => {
melden(
steht
? `${erklaerung || "Verbindung abgebrochen"} – die WebSocket-Strecke zu ` +
"TESM selbst steht (Probe erfolgreich), der Abbruch kommt also von " +
"der VNC-Seite."
: "Die WebSocket-Verbindung zu TESM kommt nicht zustande – auch eine " +
"einfache Probe scheitert. Ein Proxy zwischen Browser und TESM muss " +
"die Kopfzeilen Upgrade und Connection durchreichen (nginx: " +
"location /ws/ mit proxy_set_header Upgrade $http_upgrade). Dasselbe " +
"gilt für die Browser-Konsole.",
"fehler",
);
});
}
} }
rfb = null; rfb = null;
}); });
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);
});
// 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") { sitzungsknoepfe(false);
melden(wurzel.dataset.grund || "Diese Sitzung kann nicht aufgebaut werden.", "fehler"); melden("Wird vorbereitet …");
return;
}
const zugang = {}; fetch(quelle, { credentials: "same-origin", headers: { "X-Requested-With": "fetch" } })
if (vorgabe.username) zugang.username = vorgabe.username; .then((antwort) => antwort.json().then((inhalt) => ({ status: antwort.status, inhalt })))
if (vorgabe.password) zugang.password = vorgabe.password; .then(({ status, inhalt }) => {
verbinden(zugang); 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;
});
} }
@@ -20,6 +20,27 @@ der einen Bildschirm fernsteuert.
Datei -- mit Grund und Fundstelle, damit ein Wechsel auf eine neuere Fassung Datei -- mit Grund und Fundstelle, damit ein Wechsel auf eine neuere Fassung
nicht stillschweigend darueberlaeuft. nicht stillschweigend darueberlaeuft.
**Was TESM von aussen umbiegt** (also *nicht* in diesen Dateien steht, sondern
in `js/screen.js`):
* `RFB.prototype._fail` und `_socketClose` -- nur, um den Grund eines
Abbruchs zu erfahren. noVNC meldet ihn sonst ausschliesslich in die
Entwicklerkonsole, und `console.error` mitzulesen traegt nicht: die
Bibliothek bindet es beim Laden.
* `RFB.prototype._isSupportedSecurityType` -- **erst nachdem** ein Verfahren
die Verbindung nachweislich hat abreissen lassen. Dann wird es fuer den
zweiten Anlauf ausgelassen, damit noVNC nicht wieder dasselbe zuerst findet.
Hoechstens zwei Anlaeufe, denn jeder ist eine Verbindung, und ein RealVNC
Server zaehlt abgebrochene mit.
Hier stand einmal das Gegenteil: eine **Vorauswahl**, die ohne hinterlegte
Zugangsdaten ein Verfahren ohne Anmeldung erzwang. Das war geraten und hat
eine laufende Sitzung gebrochen. Der Unterschied ist nicht der Code, sondern
der Anlass: eine Vermutung vorher gegen einen Befund hinterher.
Faellt eine dieser Methoden in einer neueren Fassung weg, entfaellt nur die
Auskunft bzw. der zweite Anlauf -- die Sitzung laeuft wie ohne sie.
**Was noVNC an Sicherheitsverfahren spricht** (`core/rfb.js`, **Was noVNC an Sicherheitsverfahren spricht** (`core/rfb.js`,
`_isSupportedSecurityType`): `1` None, `2` VNC-Passwort, `6` RA2ne (RSA-AES `_isSupportedSecurityType`): `1` None, `2` VNC-Passwort, `6` RA2ne (RSA-AES
ohne Streckenverschluesselung), `16` Tight, `19` VeNCrypt, `22` xvp, `30` ARD, ohne Streckenverschluesselung), `16` Tight, `19` VeNCrypt, `22` xvp, `30` ARD,