Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0a048fbcc | ||
|
|
9d736bb91f | ||
|
|
914609b758 | ||
|
|
af81c92695 | ||
|
|
a528e9f3b2 | ||
|
|
3b3ce96678 | ||
|
|
42316ba137 | ||
|
|
d34baa67be | ||
|
|
491724beb5 | ||
|
|
b54b87ca6f | ||
|
|
270ba455a8 |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm"
|
name = "tesm"
|
||||||
version = "2.8.14"
|
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 = [
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 +411,14 @@ def vnc_connection(device_id: int):
|
|||||||
ziel = vnc.ziel(device)
|
ziel = vnc.ziel(device)
|
||||||
if not ziel:
|
if not ziel:
|
||||||
abort(404)
|
abort(404)
|
||||||
with transaction():
|
# Kein Eintrag in der Aenderungskette: eine Verbindungsdatei ist derselbe
|
||||||
_audit(
|
# Vorgang wie die Sitzung im Browser, nur fuer einen Viewer auf dem
|
||||||
conn,
|
# Arbeitsplatz -- und sie aendert nichts. Wer zusieht, steht im laufenden
|
||||||
"device.vnc_opened",
|
# Protokoll; das Aenderungsprotokoll bleibt die Liste der Eingriffe.
|
||||||
str(device["name"]),
|
live = core().registry.get("live_log")
|
||||||
f"Verbindungsdatei für {ziel}:{port}",
|
if live is not None:
|
||||||
|
live.info(
|
||||||
|
"vnc", f"{current_user.username} lädt die Verbindungsdatei für {device['name']}"
|
||||||
)
|
)
|
||||||
return Response(
|
return Response(
|
||||||
vnc.verbindungsdatei(
|
vnc.verbindungsdatei(
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -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,7 @@ def system():
|
|||||||
"dashboard_public",
|
"dashboard_public",
|
||||||
"vnc_enabled",
|
"vnc_enabled",
|
||||||
"vnc_port",
|
"vnc_port",
|
||||||
|
"vnc_idle_timeout",
|
||||||
"vnc_warn_unencrypted_off",
|
"vnc_warn_unencrypted_off",
|
||||||
"terminal_switches_enabled",
|
"terminal_switches_enabled",
|
||||||
"terminal_clients_enabled",
|
"terminal_clients_enabled",
|
||||||
@@ -148,6 +149,7 @@ def system_save():
|
|||||||
"dashboard_public",
|
"dashboard_public",
|
||||||
"vnc_enabled",
|
"vnc_enabled",
|
||||||
"vnc_port",
|
"vnc_port",
|
||||||
|
"vnc_idle_timeout",
|
||||||
"vnc_warn_unencrypted_off",
|
"vnc_warn_unencrypted_off",
|
||||||
"terminal_switches_enabled",
|
"terminal_switches_enabled",
|
||||||
"terminal_clients_enabled",
|
"terminal_clients_enabled",
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,8 +320,43 @@ 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 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(
|
def verbindungsdatei(
|
||||||
|
|||||||
@@ -66,6 +66,18 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
minimum=1,
|
minimum=1,
|
||||||
maximum=65535,
|
maximum=65535,
|
||||||
),
|
),
|
||||||
|
SettingSpec(
|
||||||
|
"vnc_idle_timeout",
|
||||||
|
300,
|
||||||
|
"int",
|
||||||
|
"Bildschirmsitzung bei Untätigkeit trennen nach (Sekunden)",
|
||||||
|
hint="Gezählt wird, wenn in **keine** Richtung mehr etwas fließt: der Bildschirm "
|
||||||
|
"ändert sich nicht und es kommt keine Eingabe. Eine Sitzung, die jemand offen "
|
||||||
|
"vergisst, hält sonst die Verbindung zum Gerät. 0 schaltet die Trennung ab.",
|
||||||
|
section="monitoring",
|
||||||
|
minimum=0,
|
||||||
|
maximum=86400,
|
||||||
|
),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"vnc_warn_unencrypted_off",
|
"vnc_warn_unencrypted_off",
|
||||||
False,
|
False,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,30 +64,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card__body stack">
|
<div class="card__body stack" id="screen" data-sitzung="{{ quelle }}">
|
||||||
<div id="screen-status" class="screen__status">Wird vorbereitet …</div>
|
{{ vnc_status() }}
|
||||||
|
{{ vnc_zugangsdaten() }}
|
||||||
<form id="screen-credentials" class="row row--tight" hidden onsubmit="return false">
|
{{ vnc_flaeche() }}
|
||||||
<label class="field">
|
|
||||||
<span class="field__label">Benutzer</span>
|
|
||||||
<input class="input" type="text" id="screen-username" autocomplete="off"
|
|
||||||
value="{{ vorgaben.username }}">
|
|
||||||
</label>
|
|
||||||
<label class="field">
|
|
||||||
<span class="field__label">Passwort</span>
|
|
||||||
<input class="input" type="password" id="screen-password" autocomplete="off">
|
|
||||||
</label>
|
|
||||||
<button class="btn btn--primary" type="button" id="screen-send">Anmelden</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div id="screen" class="screen"
|
|
||||||
data-socket="{{ websocket_path }}"
|
|
||||||
data-ziel="{{ zugang.ziel }}:{{ zugang.port }}"
|
|
||||||
data-verfahren="{{ zugang.verfahren | join(',') }}"
|
|
||||||
data-blockiert="{{ '1' if zugang.hinderungsgrund else '0' }}"
|
|
||||||
data-grund="{{ zugang.hinderungsgrund }}">
|
|
||||||
<div id="screen-canvas" class="screen__canvas" hidden></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if not darf_geheimnis and device.credential_id %}
|
{% if not darf_geheimnis and device.credential_id %}
|
||||||
<p class="fs-xs faint">
|
<p class="fs-xs faint">
|
||||||
|
|||||||
@@ -46,17 +46,6 @@
|
|||||||
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
|
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
|
||||||
checked=values.dashboard_public,
|
checked=values.dashboard_public,
|
||||||
hint_text=specs['dashboard_public'].hint) }}
|
hint_text=specs['dashboard_public'].hint) }}
|
||||||
{{ checkbox('vnc_enabled', 'Bildschirmzugriff (VNC) anbieten',
|
|
||||||
checked=values.vnc_enabled,
|
|
||||||
hint_text=specs['vnc_enabled'].hint) }}
|
|
||||||
<div class="form-grid">
|
|
||||||
{{ field('vnc_port', 'VNC-Port', value=values.vnc_port, type='number',
|
|
||||||
hint_text=specs['vnc_port'].hint) }}
|
|
||||||
</div>
|
|
||||||
{{ checkbox('vnc_warn_unencrypted_off',
|
|
||||||
'Warnung des Viewers vor unverschlüsselter Verbindung unterdrücken',
|
|
||||||
checked=values.vnc_warn_unencrypted_off,
|
|
||||||
hint_text=specs['vnc_warn_unencrypted_off'].hint) }}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||||
</form>
|
</form>
|
||||||
@@ -117,6 +106,39 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card__header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card__title">{{ icon('monitor', size=17) }} Bildschirm (VNC)</div>
|
||||||
|
<div class="card__sub">
|
||||||
|
Dieselben Regeln wie bei der Konsole: eigenes Recht, frische
|
||||||
|
Passwortbestätigung, getrennte Zwischenablage. Der Knopf erscheint nur, wo
|
||||||
|
ein VNC-Server antwortet – das prüft der Überwachungsdurchlauf mit.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||||
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
<div class="card__body stack">
|
||||||
|
{{ checkbox('vnc_enabled', 'Bildschirmzugriff (VNC) anbieten',
|
||||||
|
checked=values.vnc_enabled,
|
||||||
|
hint_text=specs['vnc_enabled'].hint) }}
|
||||||
|
<div class="form-grid">
|
||||||
|
{{ field('vnc_port', 'VNC-Port', value=values.vnc_port, type='number',
|
||||||
|
hint_text=specs['vnc_port'].hint) }}
|
||||||
|
{{ field('vnc_idle_timeout', 'Trennen bei Untätigkeit (Sekunden)',
|
||||||
|
value=values.vnc_idle_timeout, type='number',
|
||||||
|
hint_text=specs['vnc_idle_timeout'].hint) }}
|
||||||
|
</div>
|
||||||
|
{{ checkbox('vnc_warn_unencrypted_off',
|
||||||
|
'Warnung des Viewers vor unverschlüsselter Verbindung unterdrücken',
|
||||||
|
checked=values.vnc_warn_unencrypted_off,
|
||||||
|
hint_text=specs['vnc_warn_unencrypted_off'].hint) }}
|
||||||
|
</div>
|
||||||
|
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.8.6"
|
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,105 +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";
|
||||||
|
|
||||||
// Sicherheitsverfahren, die ohne Zugangsdaten auskommen. Bietet der Server
|
// Drei Eingriffe in private Methoden der mitgelieferten Bibliothek. Bewusst
|
||||||
// eines davon an und haben wir keine, nehmen wir es -- auch wenn der Server
|
// hier und nicht in der Bibliothek selbst: eine neue Fassung von noVNC laesst
|
||||||
// ein anderes zuerst nennt.
|
// sich dann austauschen, ohne eine Aenderung nachzupflegen -- faellt eine
|
||||||
|
// Methode weg, entfaellt nur die Auskunft, nicht die Sitzung.
|
||||||
//
|
//
|
||||||
// Warum das noetig ist: noVNC waehlt "das erste, das ich kann, in der
|
// Zwei davon holen nur Auskunft (_fail, _socketClose). Der dritte greift in
|
||||||
// Reihenfolge des Servers". Ein RealVNC Server nennt RA2ne vor "ohne
|
// die Wahl des Sicherheitsverfahrens ein -- aber erst, **nachdem** eines
|
||||||
// Authentifizierung"; der Browser liefe also in eine Passwortabfrage, obwohl
|
// nachweislich nicht getragen hat, oder weil eines schon einmal getragen hat.
|
||||||
// daneben ein Weg offensteht, den auch der RealVNC Viewer nimmt. Wer ein
|
//
|
||||||
// Passwort hinterlegt hat, behaelt die Reihenfolge des Servers -- dann ist
|
// Die Vorgeschichte gehoert dazu, damit niemand den Schritt zurueckgeht: hier
|
||||||
// der angemeldete Weg der bessere.
|
// stand einmal eine Vorauswahl, die ohne hinterlegte Zugangsdaten ein
|
||||||
const OHNE_ZUGANGSDATEN = new Set([1]);
|
// Verfahren ohne Anmeldung erzwang, weil RealVNC RA2ne zuerst nennt. Das war
|
||||||
|
// geraten und falsch. Was jetzt hier steht, ist das Gegenteil einer Vermutung:
|
||||||
// Der Eingriff sitzt in einer privaten Methode der mitgelieferten Bibliothek.
|
// ein Befund aus einer vorherigen Sitzung.
|
||||||
// Das ist bewusst hier und nicht in der Bibliothek selbst: eine neue Fassung
|
|
||||||
// von noVNC laesst sich dann austauschen, ohne eine Aenderung nachzupflegen --
|
|
||||||
// faellt die Methode weg, greift die Vorauswahl nicht mehr, und die Sitzung
|
|
||||||
// laeuft wieder wie ohne sie. Kein stiller Bruch.
|
|
||||||
const istUnterstuetzt = RFB.prototype._isSupportedSecurityType;
|
const istUnterstuetzt = RFB.prototype._isSupportedSecurityType;
|
||||||
|
|
||||||
const wurzel = document.getElementById("screen");
|
//: Den *Grund* eines Fehlschlags kennt nur ``_fail`` -- das Ereignis
|
||||||
if (wurzel) {
|
//: "disconnect" traegt ihn nicht, und der Weg ueber die Protokollausgabe
|
||||||
starten(wurzel);
|
//: funktioniert nicht: die Bibliothek bindet ``console.error`` beim Laden,
|
||||||
}
|
//: also bevor eine Seite etwas daran aendern koennte.
|
||||||
|
let letzterGrund = "";
|
||||||
|
const originalFail = RFB.prototype._fail;
|
||||||
|
RFB.prototype._fail = function (details) {
|
||||||
|
letzterGrund = String(details);
|
||||||
|
return originalFail.call(this, details);
|
||||||
|
};
|
||||||
|
|
||||||
function starten(wurzel) {
|
//: Und der Abschluss der Leitung selbst: der Schliesscode sagt, ob TESM
|
||||||
const leinwand = document.getElementById("screen-canvas");
|
//: abgewiesen hat (1008), ob es ein Serverfehler war (1011) -- oder ob die
|
||||||
const zeile = document.getElementById("screen-status");
|
//: Verbindung nie zustande kam (1006).
|
||||||
const abfrage = document.getElementById("screen-credentials");
|
let letzterCode = 0;
|
||||||
const feldBenutzer = document.getElementById("screen-username");
|
const originalSocketClose = RFB.prototype._socketClose;
|
||||||
const feldPasswort = document.getElementById("screen-password");
|
RFB.prototype._socketClose = function (ereignis) {
|
||||||
const knopfSenden = document.getElementById("screen-send");
|
letzterCode = (ereignis && ereignis.code) || 0;
|
||||||
const knopfTrennen = document.getElementById("screen-disconnect");
|
if (ereignis && ereignis.reason) {
|
||||||
const knopfStrg = document.getElementById("screen-cad");
|
letzterGrund = letzterGrund || String(ereignis.reason);
|
||||||
|
}
|
||||||
|
return originalSocketClose.call(this, ereignis);
|
||||||
|
};
|
||||||
|
|
||||||
const pfad = wurzel.dataset.socket;
|
//: Hoechstens ein zweiter Anlauf. Jeder Versuch ist eine Verbindung, und ein
|
||||||
const schema = window.location.protocol === "https:" ? "wss:" : "ws:";
|
//: RealVNC Server zaehlt abgebrochene Versuche mit -- wer dreimal anklopft,
|
||||||
const adresse = `${schema}//${window.location.host}${pfad}`;
|
//: sperrt sich aus.
|
||||||
|
const HOECHSTENS = 2;
|
||||||
|
|
||||||
const vorgabe = JSON.parse(
|
//: So lange darf "wird aufgebaut" stehen, bevor die Seite selbst nachsieht.
|
||||||
document.getElementById("screen-credentials-data")?.textContent || "{}",
|
//: 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 letzterFehler = "";
|
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 : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// noVNC meldet den *Grund* eines Fehlschlags ausschliesslich ins Protokoll
|
function sitzungsknoepfe(sichtbar) {
|
||||||
// der Entwicklerkonsole -- das Ereignis "disconnect" trägt ihn nicht. Ohne
|
knoepfeSitzung.forEach((knopf) => {
|
||||||
// diesen Griff bliebe der Seite nur "Verbindung abgebrochen", und damit
|
if (knopf) knopf.hidden = !sichtbar;
|
||||||
// steht der Anwender genau dort, wo er ohne die ganze Anzeige auch stuende.
|
});
|
||||||
// Darum wird die Fehlerausgabe waehrend der Sitzung mitgelesen -- und
|
}
|
||||||
// hinterher wieder losgelassen.
|
|
||||||
const echteAusgabe = console.error.bind(console);
|
|
||||||
console.error = (...teile) => {
|
|
||||||
letzterFehler = teile.map((teil) => String(teil)).join(" ");
|
|
||||||
echteAusgabe(...teile);
|
|
||||||
};
|
|
||||||
window.addEventListener("beforeunload", () => {
|
|
||||||
console.error = echteAusgabe;
|
|
||||||
});
|
|
||||||
|
|
||||||
function grund() {
|
function grund() {
|
||||||
const teile = [];
|
const teile = [];
|
||||||
if (letzterFehler) {
|
if (letzterGrund) {
|
||||||
teile.push(letzterFehler.replace(/^Failed (when|while) \w+: /, ""));
|
teile.push(letzterGrund.replace(/^Failed (when|while) \w+: /, ""));
|
||||||
}
|
}
|
||||||
if (rfb && rfb._rfbAuthScheme > 0) {
|
if (rfb && rfb._rfbAuthScheme > 0) {
|
||||||
teile.push(`gewähltes Verfahren: ${rfb._rfbAuthScheme}`);
|
teile.push(`gewähltes Verfahren: ${rfb._rfbAuthScheme}`);
|
||||||
}
|
}
|
||||||
|
if (gescheitert.size) {
|
||||||
|
teile.push(`erfolglos: ${[...gescheitert].join(", ")}`);
|
||||||
|
}
|
||||||
return teile.join(" · ");
|
return teile.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function verbinden(zugang) {
|
// Steht die WebSocket-Strecke zu TESM überhaupt? Zwei sehr verschiedene
|
||||||
melden("Verbindung wird aufgebaut …");
|
// Ursachen sehen von aussen gleich aus: der VNC-Server mag nicht, oder die
|
||||||
letzterFehler = "";
|
// 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Vorauswahl nur, wenn wir nichts anzubieten haben: dann zaehlt der Weg,
|
// Hat das gewählte Verfahren die Verbindung abreissen lassen? Dann ist das
|
||||||
// der ohne Zugangsdaten auskommt -- sofern der Server ihn anbietet.
|
// ein Befund und keine Vermutung -- der naechste Anlauf laesst es aus. Nur
|
||||||
const hatZugangsdaten = Boolean(zugang && (zugang.password || zugang.username));
|
// solange nie ein Bild stand: bricht eine **laufende** Sitzung ab, liegt es
|
||||||
const angeboten = (wurzel.dataset.verfahren || "")
|
// nicht am Anmeldeverfahren.
|
||||||
.split(",")
|
function naechsterVersuch() {
|
||||||
.map((wert) => parseInt(wert, 10))
|
const verfahren = rfb && rfb._rfbAuthScheme > 0 ? rfb._rfbAuthScheme : 0;
|
||||||
.filter((wert) => !Number.isNaN(wert));
|
if (!verfahren || versuche >= HOECHSTENS || gescheitert.has(verfahren)) {
|
||||||
const ohnePasswortMoeglich = angeboten.some((wert) => OHNE_ZUGANGSDATEN.has(wert));
|
return false;
|
||||||
RFB.prototype._isSupportedSecurityType = function (typ) {
|
}
|
||||||
if (!hatZugangsdaten && ohnePasswortMoeglich) {
|
gescheitert.add(verfahren);
|
||||||
return OHNE_ZUGANGSDATEN.has(typ);
|
melden(
|
||||||
|
`Verfahren ${verfahren} hat die Verbindung nicht getragen – nächster Anlauf ` +
|
||||||
|
"mit dem nächsten, das der Server anbietet …",
|
||||||
|
"warnung",
|
||||||
|
);
|
||||||
|
const zugang = letzterZugang;
|
||||||
|
rfb = null;
|
||||||
|
window.setTimeout(() => verbinden(zugang), 400);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verfahrenMelden(gewaehlt) {
|
||||||
|
if (!gewaehlt || gewaehlt === daten.bewaehrt || !daten.meldeweg) return;
|
||||||
|
fetch(daten.meldeweg, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Requested-With": "fetch",
|
||||||
|
"X-CSRF-Token": document.body?.dataset.csrf || "",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ verfahren: gewaehlt }),
|
||||||
|
}).catch(() => {
|
||||||
|
/* Eine Merkhilfe, kein Teil der Sitzung. */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function verbinden(zugang) {
|
||||||
|
if (beendet) return;
|
||||||
|
melden(versuche ? "Nächster Versuch …" : "Verbindung wird aufgebaut …");
|
||||||
|
letzterGrund = "";
|
||||||
|
letzterCode = 0;
|
||||||
|
letzterZugang = zugang;
|
||||||
|
versuche += 1;
|
||||||
|
window.clearTimeout(aufbauUhr);
|
||||||
|
aufbauUhr = window.setTimeout(() => {
|
||||||
|
// Ein ausgehandeltes RA2ne ist kein Fehler, sondern Rechenzeit: der
|
||||||
|
// Browser erzeugt dafuer gerade einen 2048-Bit-Schluessel. Das zu
|
||||||
|
// melden ist das Gegenteil einer Warnung -- es nimmt die Sorge.
|
||||||
|
if (rfb && rfb._rfbAuthScheme === RA2NE) {
|
||||||
|
melden(
|
||||||
|
"Der Server verlangt RA2ne (RSA-AES). Dafür erzeugt der Browser ein " +
|
||||||
|
"2048-Bit-Schlüsselpaar – das dauert ein bis drei Minuten. Bitte warten.",
|
||||||
|
"warnung",
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
melden("Es kommt keine Antwort. Wird geprüft, woran es liegt …", "warnung");
|
||||||
|
streckeTesten().then((steht) => {
|
||||||
|
melden(
|
||||||
|
steht
|
||||||
|
? "Die WebSocket-Strecke zu TESM steht, aber die Sitzung kommt nicht " +
|
||||||
|
"zustande. Antwortet der VNC-Server auf dem Gerät noch? Ein Neustart " +
|
||||||
|
"des VNC-Dienstes räumt eine Sperre ab."
|
||||||
|
: "Die WebSocket-Verbindung zu TESM kommt nicht zustande – auch eine " +
|
||||||
|
"einfache Probe scheitert. Ein Proxy zwischen Browser und TESM muss " +
|
||||||
|
"die Kopfzeilen Upgrade und Connection durchreichen (nginx: " +
|
||||||
|
"location /ws/ mit proxy_set_header Upgrade $http_upgrade).",
|
||||||
|
"fehler",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, AUFBAU_GRENZE);
|
||||||
|
|
||||||
|
// 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);
|
return istUnterstuetzt.call(this, typ);
|
||||||
};
|
};
|
||||||
|
|
||||||
rfb = new RFB(leinwand, adresse, {
|
rfb = new RFB(leinwand, `${schema}//${window.location.host}${daten.socket}`, {
|
||||||
credentials: zugang,
|
credentials: zugang,
|
||||||
// Eine Bildschirmsitzung soll nicht nebenbei die Zwischenablage des
|
// Eine Bildschirmsitzung soll nicht nebenbei die Zwischenablage des
|
||||||
// Arbeitsplatzes mit dem Geraet teilen -- das ist eine eigene
|
// Arbeitsplatzes mit dem Geraet teilen -- das ist eine eigene
|
||||||
@@ -118,83 +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();
|
const erklaerung = grund();
|
||||||
melden(
|
melden(
|
||||||
erklaerung
|
erklaerung
|
||||||
? `Verbindung abgebrochen – ${erklaerung}`
|
? `Verbindung abgebrochen – ${erklaerung}`
|
||||||
: "Verbindung abgebrochen, ohne dass der Client einen Grund nennt. " +
|
: "Verbindung abgebrochen. Wird geprüft, woran es liegt …",
|
||||||
"Antwortet der VNC-Server noch?",
|
|
||||||
"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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
+19
-8
@@ -21,14 +21,25 @@ Datei -- mit Grund und Fundstelle, damit ein Wechsel auf eine neuere Fassung
|
|||||||
nicht stillschweigend darueberlaeuft.
|
nicht stillschweigend darueberlaeuft.
|
||||||
|
|
||||||
**Was TESM von aussen umbiegt** (also *nicht* in diesen Dateien steht, sondern
|
**Was TESM von aussen umbiegt** (also *nicht* in diesen Dateien steht, sondern
|
||||||
in `js/screen.js`): `RFB.prototype._isSupportedSecurityType` wird ueberschrieben,
|
in `js/screen.js`):
|
||||||
solange keine Zugangsdaten vorliegen und der Server ein Verfahren ohne
|
|
||||||
Anmeldung anbietet. Grund: noVNC nimmt "das erste, das ich kann, in der
|
* `RFB.prototype._fail` und `_socketClose` -- nur, um den Grund eines
|
||||||
Reihenfolge des Servers"; ein RealVNC Server nennt RA2ne vor "ohne
|
Abbruchs zu erfahren. noVNC meldet ihn sonst ausschliesslich in die
|
||||||
Authentifizierung", und der Browser liefe in eine Passwortabfrage, obwohl
|
Entwicklerkonsole, und `console.error` mitzulesen traegt nicht: die
|
||||||
daneben der Weg offensteht, den auch der RealVNC Viewer nimmt. Faellt die
|
Bibliothek bindet es beim Laden.
|
||||||
Methode in einer neueren Fassung weg, greift die Vorauswahl nicht mehr -- die
|
* `RFB.prototype._isSupportedSecurityType` -- **erst nachdem** ein Verfahren
|
||||||
Sitzung laeuft dann wie ohne sie, es bricht nichts.
|
die Verbindung nachweislich hat abreissen lassen. Dann wird es fuer den
|
||||||
|
zweiten Anlauf ausgelassen, damit noVNC nicht wieder dasselbe zuerst findet.
|
||||||
|
Hoechstens zwei Anlaeufe, denn jeder ist eine Verbindung, und ein RealVNC
|
||||||
|
Server zaehlt abgebrochene mit.
|
||||||
|
|
||||||
|
Hier stand einmal das Gegenteil: eine **Vorauswahl**, die ohne hinterlegte
|
||||||
|
Zugangsdaten ein Verfahren ohne Anmeldung erzwang. Das war geraten und hat
|
||||||
|
eine laufende Sitzung gebrochen. Der Unterschied ist nicht der Code, sondern
|
||||||
|
der Anlass: eine Vermutung vorher gegen einen Befund hinterher.
|
||||||
|
|
||||||
|
Faellt eine dieser Methoden in einer neueren Fassung weg, entfaellt nur die
|
||||||
|
Auskunft bzw. der zweite Anlauf -- die Sitzung laeuft wie ohne sie.
|
||||||
|
|
||||||
**Was noVNC an Sicherheitsverfahren spricht** (`core/rfb.js`,
|
**Was noVNC an Sicherheitsverfahren spricht** (`core/rfb.js`,
|
||||||
`_isSupportedSecurityType`): `1` None, `2` VNC-Passwort, `6` RA2ne (RSA-AES
|
`_isSupportedSecurityType`): `1` None, `2` VNC-Passwort, `6` RA2ne (RSA-AES
|
||||||
|
|||||||
Reference in new issue
Block a user