Version 2.8.13: Bildschirmsitzung im Browser (noVNC)
This commit is contained in:
1 parent
85c40ccfbd
commit
436b080deb
73 files changed
+17426
-42
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm"
|
||||
version = "2.8.12"
|
||||
version = "2.8.13"
|
||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -100,6 +100,7 @@ def _register_blueprints(app: Flask) -> None:
|
||||
settings,
|
||||
switches,
|
||||
hostkeys,
|
||||
screen,
|
||||
terminal,
|
||||
)
|
||||
|
||||
@@ -116,6 +117,7 @@ def _register_blueprints(app: Flask) -> None:
|
||||
app.register_blueprint(license_bp.bp)
|
||||
app.register_blueprint(backup.bp)
|
||||
terminal.init_app(app)
|
||||
screen.init_app(app)
|
||||
|
||||
# Die Statusuebersicht ist die Startseite; ``index`` bleibt als Endpunkt
|
||||
# bestehen, weil der Kern (Weiterleitungen, Fehlerseiten) darauf verweist.
|
||||
|
||||
@@ -84,23 +84,17 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
||||
maintenance_active=_maintenance_active(),
|
||||
darf=current_user.has,
|
||||
),
|
||||
# Die fertige Adresse, nicht nur ein Schalter: der Knopf
|
||||
# auf der Kachel soll die Verbindung oeffnen und nicht
|
||||
# erst eine Datei ablegen, die jemand anklicken muss.
|
||||
# Der Knopf oeffnet die Sitzung in TESM selbst. Ein
|
||||
# vnc://-Link setzte einen eingerichteten Viewer auf dem
|
||||
# Arbeitsplatz voraus -- und tat dort, wo keiner
|
||||
# registriert ist, schlicht nichts.
|
||||
#
|
||||
# Beides -- ob dort ein Server antwortet und wohin gefragt
|
||||
# wurde -- steht aus dem letzten Durchlauf in der
|
||||
# Datenbank. Die Uebersicht zeigt viele Kacheln und laedt
|
||||
# sich selbst nach; ein Verbindungsversuch oder eine
|
||||
# Namensaufloesung je Kachel und Aufbau waere genau die
|
||||
# Ob dort ein Server antwortet, steht aus dem letzten
|
||||
# Durchlauf in der Datenbank. Die Uebersicht zeigt viele
|
||||
# Kacheln und laedt sich selbst nach; ein
|
||||
# Verbindungsversuch je Kachel und Aufbau waere genau die
|
||||
# Art Hintergrundlast, die niemand bestellt hat.
|
||||
"vnc": (
|
||||
vnc_dienst.Zugang(
|
||||
ziel=str(row["vnc_target"]), port=vnc_port, erreichbar=True
|
||||
).adresse
|
||||
if vnc_port and row["vnc_open"] and row["vnc_target"]
|
||||
else ""
|
||||
),
|
||||
"vnc": bool(vnc_port and row["vnc_open"]),
|
||||
}
|
||||
)
|
||||
tiles.append(entry)
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Bildschirm eines Clients im Browser -- noVNC ueber eine WebSocket-Strecke.
|
||||
|
||||
Warum ueberhaupt im Browser und nicht mit dem Viewer auf dem Arbeitsplatz: der
|
||||
Viewer muss installiert, gepflegt und mit Zugangsdaten versorgt werden, und vom
|
||||
Telefon aus gibt es ihn gar nicht. Hier genuegt die Anmeldung an TESM -- die
|
||||
Rechte, die Protokollierung und die Frage "darf dieser Mensch auf diesen
|
||||
Bildschirm?" haengen ohnehin schon dort.
|
||||
|
||||
**Die Strecke reicht durch, sie versteht nichts.** Der Server oeffnet eine
|
||||
gewoehnliche TCP-Verbindung zum VNC-Server und schaufelt Bytes in beide
|
||||
Richtungen; das RFB-Protokoll samt Authentifizierung handelt der Browser
|
||||
unmittelbar mit dem Geraet aus. Das ist Absicht: eine Gegenstelle, die das
|
||||
Protokoll auspackt, muesste die Verschluesselung aufbrechen und wieder
|
||||
aufbauen -- mehr Code an der empfindlichsten Stelle, ohne dass jemand etwas
|
||||
davon haette.
|
||||
|
||||
**Was daraus folgt** und nicht verschwiegen werden soll: weil der Browser die
|
||||
Anmeldung fuehrt, muss er die Zugangsdaten kennen. Hinterlegte Zugangsdaten
|
||||
gibt TESM deshalb nur an einen Menschen heraus, der Geheimnisse ohnehin sehen
|
||||
darf (``credentials.secrets``) -- jede Herausgabe steht im Aenderungsprotokoll.
|
||||
Wer das Recht nicht hat, tippt sie in der Seite selbst ein; sie gehen dann
|
||||
durch den Browser, aber nie durch unsere Datenbank.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, abort, render_template
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user, permission_required, reauth_required
|
||||
from tesm_core.auth.service import client_ip, reauth_is_fresh
|
||||
from tesm_core.extension import core, db
|
||||
|
||||
from ..services import inventory, vnc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint("screen", __name__)
|
||||
|
||||
#: Wie lange auf den VNC-Server gewartet wird, bis die Strecke steht.
|
||||
VERBINDUNGSLIMIT = 6.0
|
||||
|
||||
#: Groesse der Haeppchen, in denen vom Geraet gelesen wird. Ein Bildschirm
|
||||
#: liefert in Schueben; kleiner hiesse mehr Runden, groesser mehr Wartezeit,
|
||||
#: bis ein Schub weitergereicht wird.
|
||||
BLOCK = 32 * 1024
|
||||
|
||||
|
||||
def _geraet(conn: Any, device_id: int) -> dict[str, Any] | None:
|
||||
device = inventory.get_device(conn, device_id)
|
||||
if device is None or device.get("deleted_at"):
|
||||
return None
|
||||
return device
|
||||
|
||||
|
||||
def _port(conn: Any) -> int:
|
||||
extension = core()
|
||||
if not extension.settings.get(conn, "vnc_enabled"):
|
||||
return 0
|
||||
return int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT)
|
||||
|
||||
|
||||
@bp.get("/monitoring/clients/<int:device_id>/screen")
|
||||
@permission_required("device_vnc.execute")
|
||||
@reauth_required
|
||||
def screen(device_id: int):
|
||||
"""Die Seite mit der Leinwand.
|
||||
|
||||
Sie misst vor dem Verbinden, **wie** der VNC-Server sich authentifizieren
|
||||
will. Bietet er nur Verfahren an, die der Browser-Client nicht kennt, steht
|
||||
das als Satz auf der Seite -- mit der Zeile, die das auf dem Geraet aendert.
|
||||
Der Unterschied zur schwarzen Flaeche ist der ganze Punkt: wer nicht weiss,
|
||||
woran es liegt, sucht den Fehler bei TESM.
|
||||
"""
|
||||
conn = db()
|
||||
device = _geraet(conn, device_id)
|
||||
if device is None:
|
||||
abort(404)
|
||||
port = _port(conn)
|
||||
if not port:
|
||||
abort(404)
|
||||
|
||||
zugang = vnc.zugang(device, port=port, pruefen=str(device.get("state") or "") == "online")
|
||||
benutzer, passwort = "", ""
|
||||
darf_geheimnis = bool(current_user.has("credentials.secrets"))
|
||||
if device.get("credential_id") and darf_geheimnis:
|
||||
try:
|
||||
benutzer, passwort = inventory.credential_secret(
|
||||
conn, core().keystore, int(device["credential_id"])
|
||||
)
|
||||
except inventory.ValidationError:
|
||||
benutzer, passwort = "", ""
|
||||
if passwort:
|
||||
with core().database.transaction(conn):
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="device.vnc_credentials_used",
|
||||
target=str(device["name"]),
|
||||
detail=(
|
||||
f"Hinterlegte Zugangsdaten ({benutzer}) an die Bildschirmsitzung "
|
||||
"übergeben"
|
||||
),
|
||||
ip=client_ip(),
|
||||
severity="warning",
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"tesm/screen.html",
|
||||
device=device,
|
||||
zugang=zugang,
|
||||
websocket_path=f"/ws/screen/{device_id}",
|
||||
vorgaben={"username": benutzer, "password": passwort},
|
||||
darf_geheimnis=darf_geheimnis,
|
||||
verfahren_name=vnc.verfahren_name,
|
||||
)
|
||||
|
||||
|
||||
def init_app(app) -> None: # type: ignore[no-untyped-def]
|
||||
"""Meldet Seite und -- falls verfuegbar -- die WebSocket-Strecke an."""
|
||||
app.register_blueprint(bp)
|
||||
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
except ImportError: # pragma: no cover - optionale Abhaengigkeit
|
||||
logger.info("flask-sock fehlt – die Bildschirmsitzung bleibt abgeschaltet.")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
|
||||
@sock.route("/ws/screen/<int:device_id>")
|
||||
def screen_socket(ws, device_id: int): # type: ignore[no-untyped-def]
|
||||
_strecke(ws, device_id)
|
||||
|
||||
|
||||
def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def]
|
||||
"""Bytes hin, Bytes her -- und sonst nichts.
|
||||
|
||||
Die Pruefungen stehen hier ausdruecklich noch einmal: ein WebSocket geht an
|
||||
den Dekoratoren der HTTP-Route vorbei.
|
||||
"""
|
||||
extension = core()
|
||||
conn = db()
|
||||
|
||||
if not current_user.is_authenticated or not current_user.has("device_vnc.execute"):
|
||||
ws.close(1008, "Keine Berechtigung für den Bildschirm dieses Clients.")
|
||||
return
|
||||
if not reauth_is_fresh():
|
||||
ws.close(1008, "Die Passwortbestätigung ist abgelaufen.")
|
||||
return
|
||||
port = _port(conn)
|
||||
if not port:
|
||||
ws.close(1008, "Der Bildschirmzugriff ist in dieser Installation abgeschaltet.")
|
||||
return
|
||||
device = _geraet(conn, device_id)
|
||||
if device is None:
|
||||
ws.close(1008, "Client nicht gefunden.")
|
||||
return
|
||||
host = vnc.ziel(device)
|
||||
if not host:
|
||||
ws.close(1008, "Für diesen Client ist kein Ziel bekannt.")
|
||||
return
|
||||
|
||||
try:
|
||||
gegenstelle = socket.create_connection((host, port), timeout=VERBINDUNGSLIMIT)
|
||||
except OSError as exc:
|
||||
ws.close(1011, f"{host}:{port} antwortet nicht ({exc}).")
|
||||
return
|
||||
|
||||
begonnen = time.monotonic()
|
||||
with extension.database.transaction(conn):
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="device.screen_opened",
|
||||
target=str(device["name"]),
|
||||
detail=f"Bildschirmsitzung zu {host}:{port}",
|
||||
ip=client_ip(),
|
||||
severity="warning",
|
||||
)
|
||||
|
||||
laeuft = threading.Event()
|
||||
laeuft.set()
|
||||
|
||||
def vom_geraet() -> None:
|
||||
"""Alles, was der VNC-Server schickt, geht als Binaerrahmen weiter."""
|
||||
try:
|
||||
while laeuft.is_set():
|
||||
daten = gegenstelle.recv(BLOCK)
|
||||
if not daten:
|
||||
break
|
||||
ws.send(daten)
|
||||
except Exception: # noqa: BLE001 - Abbruch ist der Normalfall, nicht die Stoerung
|
||||
pass
|
||||
finally:
|
||||
laeuft.clear()
|
||||
try:
|
||||
ws.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
leser = threading.Thread(target=vom_geraet, name=f"vnc-{device_id}", daemon=True)
|
||||
leser.start()
|
||||
|
||||
try:
|
||||
while laeuft.is_set():
|
||||
nachricht = ws.receive()
|
||||
if nachricht is None:
|
||||
break
|
||||
if isinstance(nachricht, str):
|
||||
# Der Browser-Client schickt ausschliesslich Binaerrahmen. Text
|
||||
# waere entweder ein Irrtum oder ein Versuch -- beides endet
|
||||
# hier und nicht auf dem Geraet.
|
||||
continue
|
||||
gegenstelle.sendall(nachricht)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Bildschirmsitzung beendet (%s)", device["name"], exc_info=True)
|
||||
finally:
|
||||
laeuft.clear()
|
||||
try:
|
||||
gegenstelle.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
gegenstelle.close()
|
||||
dauer = int(time.monotonic() - begonnen)
|
||||
with extension.database.session() as frisch, extension.database.transaction(frisch):
|
||||
audit.log(
|
||||
frisch,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="device.screen_closed",
|
||||
target=str(device["name"]),
|
||||
detail=f"Dauer {dauer}s",
|
||||
ip=client_ip(),
|
||||
severity="info",
|
||||
)
|
||||
@@ -515,6 +515,17 @@ ALTER TABLE device_status ADD COLUMN vnc_target TEXT NOT NULL DEFAULT '';
|
||||
"""
|
||||
|
||||
|
||||
# Version 12 ----------------------------------------------------------------
|
||||
#
|
||||
# Welche Sicherheitsverfahren der VNC-Server angeboten hat. Daran entscheidet
|
||||
# sich, ob eine Sitzung im Browser ueberhaupt zustande kommen kann -- und wer
|
||||
# das erst erfaehrt, wenn er vor einer schwarzen Flaeche sitzt, sucht den
|
||||
# Fehler bei uns.
|
||||
V12 = """
|
||||
ALTER TABLE device_status ADD COLUMN vnc_types TEXT NOT NULL DEFAULT '';
|
||||
"""
|
||||
|
||||
|
||||
def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
|
||||
"""Aus sieben Standardgruppen werden drei -- ohne jemandem etwas wegzunehmen.
|
||||
|
||||
@@ -559,4 +570,5 @@ TESM_MIGRATIONS: tuple[Migration, ...] = (
|
||||
Migration(version=9, name="standardgruppen-aufraeumen", fn=uebergang_standardgruppen),
|
||||
Migration(version=10, name="vnc-erreichbarkeit", sql=V10),
|
||||
Migration(version=11, name="vnc-ziel", sql=V11),
|
||||
Migration(version=12, name="vnc-verfahren", sql=V12),
|
||||
)
|
||||
@@ -543,7 +543,8 @@ def list_devices(
|
||||
COALESCE(st.state,'unknown') AS state, st.latency_ms, st.last_check_at,
|
||||
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,
|
||||
COALESCE(st.vnc_target, '') AS vnc_target
|
||||
COALESCE(st.vnc_target, '') AS vnc_target,
|
||||
COALESCE(st.vnc_types, '') AS vnc_types
|
||||
FROM devices d
|
||||
LEFT JOIN switches s ON s.id = d.switch_id AND s.deleted_at IS NULL
|
||||
LEFT JOIN credentials c ON c.id = d.credential_id AND c.deleted_at IS NULL
|
||||
|
||||
@@ -508,7 +508,11 @@ class SweepReport:
|
||||
|
||||
|
||||
def merke_vnc(
|
||||
conn: sqlite3.Connection, device_id: int, offen: bool, ziel: str = ""
|
||||
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.
|
||||
|
||||
@@ -518,9 +522,15 @@ def merke_vnc(
|
||||
geschrieben, von dem, der ohnehin gefragt hat.
|
||||
"""
|
||||
conn.execute(
|
||||
"UPDATE device_status SET vnc_open=?, vnc_checked_at=?, vnc_target=? "
|
||||
"UPDATE device_status SET vnc_open=?, vnc_checked_at=?, vnc_target=?, vnc_types=? "
|
||||
"WHERE device_id=?",
|
||||
(1 if offen else 0, _now(), ziel, device_id),
|
||||
(
|
||||
1 if offen else 0,
|
||||
_now(),
|
||||
ziel,
|
||||
",".join(str(v) for v in verfahren),
|
||||
device_id,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -558,15 +568,14 @@ def run_sweep(
|
||||
|
||||
def pruefen(
|
||||
device: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], PingResult, tuple[bool, str] | None]:
|
||||
) -> tuple[dict[str, Any], PingResult, vnc_dienst.Zugang | None]:
|
||||
ergebnis = ping(str(device["ip"]))
|
||||
if not vnc_port or not ergebnis.ok:
|
||||
# Kein Ergebnis heisst "nicht gefragt" -- und laesst den zuletzt
|
||||
# bekannten Stand stehen. Ein Geraet, das gerade neu startet, soll
|
||||
# seinen Knopf nicht verlieren.
|
||||
return device, ergebnis, None
|
||||
ziel = vnc_dienst.ziel(device)
|
||||
return device, ergebnis, (vnc_dienst.erreichbar(ziel, vnc_port), ziel)
|
||||
return device, ergebnis, vnc_dienst.zugang(device, port=vnc_port)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool:
|
||||
results = list(pool.map(pruefen, devices))
|
||||
@@ -578,7 +587,13 @@ def run_sweep(
|
||||
report.checked += 1
|
||||
previous, current = record_status(conn, int(device["id"]), result)
|
||||
if vnc_offen is not None:
|
||||
merke_vnc(conn, int(device["id"]), vnc_offen[0], vnc_offen[1])
|
||||
merke_vnc(
|
||||
conn,
|
||||
int(device["id"]),
|
||||
vnc_offen.erreichbar,
|
||||
vnc_offen.ziel,
|
||||
vnc_offen.verfahren,
|
||||
)
|
||||
if result.ok:
|
||||
report.online += 1
|
||||
else:
|
||||
|
||||
@@ -57,6 +57,35 @@ class Zugang:
|
||||
ziel: str
|
||||
port: int
|
||||
erreichbar: bool
|
||||
#: Die Sicherheitsverfahren, die der Server angeboten hat (leer: nicht
|
||||
#: gemessen). Daraus entsteht der Satz, warum eine Sitzung im Browser
|
||||
#: gehen wird oder nicht.
|
||||
verfahren: tuple[int, ...] = ()
|
||||
|
||||
@property
|
||||
def browsertauglich(self) -> bool:
|
||||
"""Ist mindestens ein angebotenes Verfahren eines, das noVNC kann?"""
|
||||
return bool(set(self.verfahren) & BROWSER_VERFAHREN)
|
||||
|
||||
@property
|
||||
def hinderungsgrund(self) -> str:
|
||||
"""Warum eine Sitzung im Browser nicht zustande kaeme -- im Klartext."""
|
||||
if not self.erreichbar:
|
||||
return f"Auf {self.ziel}:{self.port} antwortet kein VNC-Server."
|
||||
if not self.verfahren:
|
||||
return ""
|
||||
if self.browsertauglich:
|
||||
return ""
|
||||
angeboten = ", ".join(verfahren_name(v) for v in self.verfahren)
|
||||
return (
|
||||
f"Der VNC-Server bietet nur {angeboten} an. Der Browser-Client kann "
|
||||
"davon keines: herstellereigene Verfahren sind nicht offengelegt, "
|
||||
"und RA2 (5) verschlüsselt die ganze Strecke mit einem Verfahren, "
|
||||
"das er nicht mitbringt. Auf dem Gerät hilft eine Zeile: "
|
||||
"Encryption=AlwaysOff (dann bietet der Server RA2ne an) oder "
|
||||
"Authentication=VncAuth samt vncpasswd (dann das klassische "
|
||||
"VNC-Passwort)."
|
||||
)
|
||||
|
||||
@property
|
||||
def adresse(self) -> str:
|
||||
@@ -122,10 +151,88 @@ def erreichbar(host: str, port: int = STANDARD_PORT) -> bool:
|
||||
return tcp_reachable(host, port, timeout=ZEITLIMIT)
|
||||
|
||||
|
||||
#: Was der Browser-Client (noVNC 1.6) aushandeln kann. Aus dessen
|
||||
#: ``_isSupportedSecurityType`` uebernommen -- steht hier, weil die Oberflaeche
|
||||
#: **vor** dem Verbinden sagen koennen soll, ob es klappen wird.
|
||||
BROWSER_VERFAHREN: frozenset[int] = frozenset({1, 2, 6, 16, 19, 22, 30, 113})
|
||||
|
||||
#: Die Namen dazu. Was nicht drinsteht, ist herstellereigen -- die Nummern
|
||||
#: 7 bis 15 gehoeren RealVNC, 3 und 4 ebenfalls.
|
||||
VERFAHREN_NAMEN: dict[int, str] = {
|
||||
1: "ohne Authentifizierung",
|
||||
2: "VNC-Passwort",
|
||||
5: "RA2 (RSA-AES, verschlüsselt)",
|
||||
6: "RA2ne (RSA-AES, unverschlüsselt)",
|
||||
16: "Tight",
|
||||
18: "TLS",
|
||||
19: "VeNCrypt",
|
||||
22: "xvp",
|
||||
30: "Apple ARD",
|
||||
113: "MS-Logon II",
|
||||
}
|
||||
|
||||
|
||||
def verfahren_name(nummer: int) -> str:
|
||||
"""Lesbarer Name eines Sicherheitsverfahrens."""
|
||||
if nummer in VERFAHREN_NAMEN:
|
||||
return f"{nummer} ({VERFAHREN_NAMEN[nummer]})"
|
||||
if 3 <= nummer <= 15:
|
||||
return f"{nummer} (RealVNC-eigen)"
|
||||
return str(nummer)
|
||||
|
||||
|
||||
def sicherheitsverfahren(host: str, port: int = STANDARD_PORT) -> tuple[str, tuple[int, ...]]:
|
||||
"""Fragt den Server, **wie** er sich authentifizieren will.
|
||||
|
||||
Der Anfang einer VNC-Verbindung ist fuer jeden Server derselbe: er nennt
|
||||
seine Protokollfassung, der Client die seine, dann kommt die Liste der
|
||||
Sicherheitsverfahren. Weiter gehen wir nicht -- die Verbindung wird sofort
|
||||
geschlossen, es wird nichts angemeldet und nichts geraten.
|
||||
|
||||
Das ist die Angabe, an der eine Bildschirmsitzung im Browser scheitert oder
|
||||
gelingt, und sie hier zu messen ist der Unterschied zwischen einer
|
||||
schwarzen Flaeche und dem Satz "dieser Server bietet nur Verfahren an, die
|
||||
der Browser nicht kennt". Gibt ``(Fassung, Verfahren)`` zurueck; bei einem
|
||||
Fehler ``("", ())``.
|
||||
"""
|
||||
import socket
|
||||
|
||||
try:
|
||||
with socket.create_connection((host, int(port)), timeout=ZEITLIMIT) as sock:
|
||||
sock.settimeout(ZEITLIMIT)
|
||||
gruss = sock.recv(12)
|
||||
if not gruss.startswith(b"RFB "):
|
||||
return "", ()
|
||||
fassung = gruss[4:11].decode("ascii", "replace")
|
||||
# Wir antworten mit 3.8 -- der Fassung, die auch der Browser
|
||||
# spricht. Ein Server, der mehr kann, faellt darauf zurueck und
|
||||
# nennt trotzdem seine Verfahren.
|
||||
sock.sendall(b"RFB 003.008" + bytes([10]))
|
||||
anzahl = sock.recv(1)
|
||||
if not anzahl:
|
||||
return fassung, ()
|
||||
wie_viele = anzahl[0]
|
||||
if wie_viele == 0:
|
||||
# Null heisst: der Server lehnt ab und schickt den Grund.
|
||||
return fassung, ()
|
||||
liste = b""
|
||||
while len(liste) < wie_viele:
|
||||
stueck = sock.recv(wie_viele - len(liste))
|
||||
if not stueck:
|
||||
break
|
||||
liste += stueck
|
||||
return fassung, tuple(liste)
|
||||
except OSError:
|
||||
return "", ()
|
||||
|
||||
|
||||
def zugang(device: Mapping[str, Any], *, port: int = STANDARD_PORT, pruefen: bool = True) -> Zugang:
|
||||
"""Ziel und Erreichbarkeit in einem Stueck -- so nimmt es die Vorlage."""
|
||||
"""Ziel, Erreichbarkeit und Verfahren in einem Stueck -- so nimmt es die Vorlage."""
|
||||
host = ziel(device)
|
||||
return Zugang(ziel=host, port=port, erreichbar=erreichbar(host, port) if pruefen else False)
|
||||
if not pruefen:
|
||||
return Zugang(ziel=host, port=port, erreichbar=False)
|
||||
fassung, verfahren = sicherheitsverfahren(host, port)
|
||||
return Zugang(ziel=host, port=port, erreichbar=bool(fassung), verfahren=verfahren)
|
||||
|
||||
|
||||
def verbindungsdatei(device: Mapping[str, Any], *, port: int = STANDARD_PORT) -> str:
|
||||
|
||||
@@ -101,12 +101,10 @@
|
||||
{% if tile.vnc or tile.restart_methods %}
|
||||
<div class="tile__actions">
|
||||
{% if tile.vnc %}
|
||||
{# Unmittelbar die Verbindung, keine Datei: auf der
|
||||
Uebersicht will man hin, nicht etwas herunterladen.
|
||||
Die Verbindungsdatei fuer Viewer ohne registriertes
|
||||
Schema steht auf der Detailseite. #}
|
||||
<a class="btn btn--sm" href="{{ tile.vnc }}"
|
||||
title="Bildschirm öffnen ({{ tile.vnc }})">
|
||||
{# Die Sitzung laeuft in TESM selbst: ein Klick, kein
|
||||
Viewer auf dem Arbeitsplatz, keine Datei. #}
|
||||
<a class="btn btn--sm" href="{{ url_for('screen.screen', device_id=tile.id) }}"
|
||||
title="Bildschirm im Browser öffnen">
|
||||
{{ icon('monitor', size=14) }} VNC
|
||||
</a>
|
||||
{% else %}
|
||||
|
||||
@@ -17,11 +17,8 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if vnc and vnc.erreichbar %}
|
||||
{# Wie auf der Statusübersicht: der Knopf öffnet die Verbindung. Die
|
||||
Verbindungsdatei steht eine Zeile tiefer in den Stammdaten -- für
|
||||
Arbeitsplätze, an denen kein Viewer das Schema übernimmt. #}
|
||||
<a class="btn btn--sm" href="{{ vnc.adresse }}"
|
||||
title="Bildschirm öffnen ({{ vnc.adresse }})">
|
||||
<a class="btn btn--sm" href="{{ url_for('screen.screen', device_id=device.id) }}"
|
||||
title="Bildschirm im Browser öffnen">
|
||||
{{ icon('monitor', size=15) }} VNC
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -238,12 +235,16 @@
|
||||
<dt>Bildschirm (VNC)</dt>
|
||||
<dd>
|
||||
{% if vnc.erreichbar %}
|
||||
<a class="mono" href="{{ vnc.adresse }}">{{ vnc.adresse }}</a>
|
||||
<span class="mono">{{ vnc.ziel }}:{{ vnc.port }}</span>
|
||||
<div class="fs-xs faint">
|
||||
Öffnet den Viewer unmittelbar, sofern er <code>vnc://</code> übernimmt.
|
||||
Sonst die
|
||||
<a href="{{ url_for('screen.screen', device_id=device.id) }}">
|
||||
Im Browser öffnen</a> – oder die
|
||||
<a href="{{ url_for('devices.vnc_connection', device_id=device.id) }}">
|
||||
Verbindungsdatei</a> – sie enthält nur Ziel und Namen.
|
||||
Verbindungsdatei</a> für einen Viewer auf dem Arbeitsplatz; sie
|
||||
enthält nur Ziel und Namen.
|
||||
{% if vnc.hinderungsgrund %}
|
||||
<div class="mt-1">{{ vnc.hinderungsgrund }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="faint">
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import alert, pill %}
|
||||
|
||||
{% block page_title %}Bildschirm: {{ device.name }}{% endblock %}
|
||||
{% block page_sub %}{{ zugang.ziel }}:{{ zugang.port }}{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb">
|
||||
<span><a href="{{ url_for('devices.overview') }}">Clients</a></span>
|
||||
<span><a href="{{ url_for('devices.detail', device_id=device.id) }}">{{ device.name }}</a></span>
|
||||
<span>Bildschirm</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# Die Zugangsdaten stehen in einem eigenen JSON-Block statt in einem
|
||||
data-Attribut: ein Attribut steht im Quelltext der Seite zwischen der
|
||||
Beschriftung und wird beim Kopieren einer Zeile mitgenommen. Beides ist
|
||||
derselbe Inhalt -- der Unterschied ist, wie leicht er einem versehentlich
|
||||
unter die Augen kommt. #}
|
||||
<script type="application/json" id="screen-credentials-data">{{ vorgaben | tojson }}</script>
|
||||
|
||||
<div class="stack">
|
||||
{% if zugang.hinderungsgrund %}
|
||||
{{ alert(zugang.hinderungsgrund, 'warning') }}
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('monitor', size=17) }} {{ device.name }}
|
||||
{% if zugang.verfahren %}
|
||||
{% if zugang.browsertauglich %}{{ pill('Browser-Sitzung möglich', 'online') }}
|
||||
{% else %}{{ pill('Verfahren nicht unterstützt', 'warning') }}{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card__sub">
|
||||
Tastatur und Maus gehen an das Gerät, sobald das Bild steht. Die Zwischenablage
|
||||
bleibt getrennt.
|
||||
{% if zugang.verfahren %}
|
||||
Angebotene Anmeldeverfahren:
|
||||
{% for nummer in zugang.verfahren %}{{ verfahren_name(nummer) }}{% if not loop.last %}, {% endif %}{% endfor %}.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row row--tight">
|
||||
<button class="btn btn--sm" type="button" id="screen-cad" hidden
|
||||
title="Strg+Alt+Entf an das Gerät senden">Strg+Alt+Entf</button>
|
||||
<button class="btn btn--sm" type="button" id="screen-disconnect" hidden>Trennen</button>
|
||||
<a class="btn btn--sm btn--ghost"
|
||||
href="{{ url_for('devices.vnc_connection', device_id=device.id) }}">
|
||||
{{ icon('download', size=14) }} Verbindungsdatei
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card__body stack">
|
||||
<div id="screen-status" class="screen__status">Wird vorbereitet …</div>
|
||||
|
||||
<form id="screen-credentials" class="row row--tight" hidden onsubmit="return false">
|
||||
<label class="field">
|
||||
<span class="field__label">Benutzer</span>
|
||||
<input class="input" type="text" id="screen-username" autocomplete="off"
|
||||
value="{{ vorgaben.username }}">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Passwort</span>
|
||||
<input class="input" type="password" id="screen-password" autocomplete="off">
|
||||
</label>
|
||||
<button class="btn btn--primary" type="button" id="screen-send">Anmelden</button>
|
||||
</form>
|
||||
|
||||
<div id="screen" class="screen"
|
||||
data-socket="{{ websocket_path }}"
|
||||
data-ziel="{{ zugang.ziel }}:{{ zugang.port }}"
|
||||
data-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 %}
|
||||
<p class="fs-xs faint">
|
||||
Für diesen Client sind Zugangsdaten hinterlegt. TESM gibt sie nur an Konten
|
||||
heraus, die Geheimnisse ohnehin einsehen dürfen – sonst wären sie über den
|
||||
Umweg einer Bildschirmsitzung für jeden lesbar, der sie öffnen darf. Tragen
|
||||
Sie sie oben ein; sie werden nicht gespeichert.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="module" src="{{ asset_url('js/screen.js') }}" nonce="{{ csp_nonce }}"></script>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user