Version 2.8.22: Alt+F4, Sitzung im Fenster, bewaehrtes Verfahren zuerst

This commit is contained in:
alientim committed 2026-09-13 14:30:37 +02:00
1 parent a528e9f3b2
commit af81c92695
14 files changed
+463 -188

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm"
version = "2.8.21"
version = "2.8.22"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11"
dependencies = [
+16 -1
View File
@@ -14,7 +14,7 @@ from __future__ import annotations
from typing import Any
from flask import Blueprint, abort, render_template, request
from flask import Blueprint, abort, render_template, request, url_for
from tesm_core.security.sessions import fragment
from tesm_core.extension import core, db
@@ -49,6 +49,13 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
# Einmal je Seitenaufbau, nicht je Kachel: der Port ist fuer alle derselbe,
# und ohne das Recht wird gar nicht erst nachgesehen.
vnc_erlaubt = detailed and current_user.has("device_vnc.execute")
# Das Fenster setzt eine frische Passwortbestaetigung voraus -- dieselbe
# Schwelle wie die Geraeteseite. Fehlt sie, fuehrt der Knopf dorthin: dort
# wird danach gefragt, und ein Fenster, das sich mit "bitte erst
# bestaetigen" oeffnet, waere ein Klick fuer nichts.
from tesm_core.auth.service import reauth_is_fresh
vnc_fenster = vnc_erlaubt and reauth_is_fresh()
vnc_port = (
int(core().settings.get(conn, "vnc_port") or vnc_dienst.STANDARD_PORT)
if vnc_erlaubt and core().settings.get(conn, "vnc_enabled")
@@ -95,9 +102,16 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
# Verbindungsversuch je Kachel und Aufbau waere genau die
# Art Hintergrundlast, die niemand bestellt hat.
"vnc": bool(vnc_port and row["vnc_open"]),
"vnc_quelle": (
url_for("screen.session_json", device_id=int(row["id"]))
if vnc_port and row["vnc_open"]
else ""
),
}
)
tiles.append(entry)
# Merker fuer die Vorlage: nur mit frischer Bestaetigung gibt es das
# Fenster; sonst zeigt die Kachel auf die Seite.
sections = [
{
@@ -142,6 +156,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
"counts": counts,
"sections": sections,
"detailed": detailed,
"vnc_fenster": vnc_fenster,
"interval": int(core().settings.get(conn, "monitor_interval_seconds") or 300),
}
+117 -29
View File
@@ -30,7 +30,15 @@ import threading
import time
from typing import Any
from flask import Blueprint, abort, flash, redirect, render_template, url_for
from flask import (
Blueprint,
abort,
flash,
redirect,
render_template,
request,
url_for,
)
from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required, reauth_required
from tesm_core.auth.service import client_ip, reauth_is_fresh
@@ -92,42 +100,51 @@ def screen(device_id: int):
abort(404)
zugang = vnc.zugang_gemerkt(device, port=port)
benutzer, passwort = "", ""
darf_geheimnis = bool(current_user.has("credentials.secrets"))
if device.get("credential_id") and darf_geheimnis:
try:
benutzer, passwort = inventory.credential_secret(
conn, core().keystore, int(device["credential_id"])
)
except inventory.ValidationError:
benutzer, passwort = "", ""
if passwort:
with core().database.transaction(conn):
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="device.vnc_credentials_used",
target=str(device["name"]),
detail=(
f"Hinterlegte Zugangsdaten ({benutzer}) an die Bildschirmsitzung "
"übergeben"
),
ip=client_ip(),
severity="warning",
)
return render_template(
"tesm/screen.html",
device=device,
zugang=zugang,
websocket_path=f"/ws/screen/{device_id}",
vorgaben={"username": benutzer, "password": passwort},
darf_geheimnis=darf_geheimnis,
quelle=url_for("screen.session_json", device_id=device_id),
darf_geheimnis=bool(current_user.has("credentials.secrets")),
verfahren_name=vnc.verfahren_name,
)
def _zugangsdaten(conn: Any, device: dict[str, Any]) -> tuple[str, str]:
"""Hinterlegte Zugangsdaten -- nur fuer den, der Geheimnisse sehen darf.
Weil der Browser die Anmeldung fuehrt, muessen sie in die Seite. Wer
Geheimnisse nicht sehen darf, bekommt sie auch hier nicht: sonst waere die
Bildschirmsitzung der Umweg, auf dem jeder das Passwort liest. Jede
Herausgabe steht im Aenderungsprotokoll -- das ist kein Zusehen, sondern
ein Zugriff auf etwas, das sonst niemand sieht.
"""
if not device.get("credential_id") or not current_user.has("credentials.secrets"):
return "", ""
try:
benutzer, passwort = inventory.credential_secret(
conn, core().keystore, int(device["credential_id"])
)
except inventory.ValidationError:
return "", ""
if passwort:
with core().database.transaction(conn):
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="device.vnc_credentials_used",
target=str(device["name"]),
detail=(
f"Hinterlegte Zugangsdaten ({benutzer}) an die Bildschirmsitzung "
"übergeben"
),
ip=client_ip(),
severity="warning",
)
return benutzer, passwort
@bp.post("/monitoring/clients/<int:device_id>/screen/probe")
@permission_required("device_vnc.execute")
@reauth_required
@@ -165,6 +182,73 @@ def probe(device_id: int):
return redirect(url_for("screen.screen", device_id=device_id))
@bp.get("/monitoring/clients/<int:device_id>/screen/session")
@permission_required("device_vnc.execute")
def session_json(device_id: int):
"""Alles, was der Client fuer eine Sitzung braucht -- als Auskunft.
Warum nicht in die Seite geschrieben: dieselbe Sitzung laeuft auch im
Fenster der Statusuebersicht, und dort stuenden sonst die Zugangsdaten
**jedes** Geraets im Quelltext der Seite. So holt sie sich nur, wer eine
Sitzung wirklich oeffnet -- und nur fuer das eine Geraet.
Die frische Passwortbestaetigung wird hier von Hand geprueft statt ueber
den Dekorator: der wuerde auf eine Seite umleiten, und eine Umleitung ist
fuer einen Abruf im Hintergrund keine Auskunft. 409 mit einem Hinweis ist
eine.
"""
conn = db()
device = _geraet(conn, device_id)
if device is None:
abort(404)
port = _port(conn)
if not port:
abort(404)
if not reauth_is_fresh():
return {"reauth": True}, 409
zugang = vnc.zugang_gemerkt(device, port=port)
benutzer, passwort = _zugangsdaten(conn, device)
return {
"name": str(device["name"]),
"socket": f"/ws/screen/{device_id}",
"ziel": f"{zugang.ziel}:{zugang.port}",
"verfahren": list(zugang.verfahren),
"bewaehrt": zugang.bewaehrt,
"hinderungsgrund": zugang.hinderungsgrund,
"meldeweg": url_for("screen.merke_verfahren", device_id=device_id),
"vorgaben": {"username": benutzer, "password": passwort},
}
@bp.post("/monitoring/clients/<int:device_id>/screen/method")
@permission_required("device_vnc.execute")
def merke_verfahren(device_id: int):
"""Der Browser meldet, womit die Sitzung zustande kam.
Nur er weiss es: die Strecke reicht Bytes durch und liest das Protokoll
nicht. Beim naechsten Mal faengt die Seite damit an, statt wieder bei der
ersten Wahl des Servers -- die hier schon einmal abgerissen ist und deren
Absage sich ein sperrender RealVNC Server Minuten Zeit laesst.
"""
conn = db()
device = _geraet(conn, device_id)
if device is None:
abort(404)
try:
verfahren = int(request.json.get("verfahren", 0)) if request.json else 0
except (TypeError, ValueError):
verfahren = 0
if not 0 < verfahren < 256:
return {"ok": False}, 400
from ..services import monitor
with core().database.transaction(conn):
monitor.merke_verfahren_das_traegt(conn, int(device["id"]), verfahren)
return {"ok": True}
def init_app(app) -> None: # type: ignore[no-untyped-def]
"""Meldet Seite und -- falls verfuegbar -- die WebSocket-Strecke an."""
app.register_blueprint(bp)
@@ -246,6 +330,10 @@ def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def]
# Stattdessen ein kurzer Takt: das Lesen laeuft immer wieder in ein
# 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)
+14
View File
@@ -539,6 +539,19 @@ ALTER TABLE device_status ADD COLUMN vnc_types_at TEXT;
"""
# Version 14 ----------------------------------------------------------------
#
# Welches Anmeldeverfahren zuletzt wirklich getragen hat. Der Server nennt
# seine Reihenfolge, und die gilt -- aber wenn seine erste Wahl schon einmal
# abgerissen ist und eine andere zum Bild gefuehrt hat, ist das ein Befund.
# Ihn zu vergessen kostet bei jedem Oeffnen einen Fehlversuch, und RealVNC
# laesst sich mit der Absage Zeit: gemessen zwei bis drei Minuten, bis das
# Bild stand.
V14 = """
ALTER TABLE device_status ADD COLUMN vnc_method INTEGER NOT NULL DEFAULT 0;
"""
def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
"""Aus sieben Standardgruppen werden drei -- ohne jemandem etwas wegzunehmen.
@@ -585,4 +598,5 @@ TESM_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=11, name="vnc-ziel", sql=V11),
Migration(version=12, name="vnc-verfahren", sql=V12),
Migration(version=13, name="vnc-absage", sql=V13),
Migration(version=14, name="vnc-verfahren-das-traegt", sql=V14),
)
+2 -1
View File
@@ -545,7 +545,8 @@ def list_devices(
st.fail_count, COALESCE(st.vnc_open, 0) AS vnc_open, st.vnc_checked_at,
COALESCE(st.vnc_target, '') AS vnc_target,
COALESCE(st.vnc_types, '') AS vnc_types,
COALESCE(st.vnc_reason, '') AS vnc_reason, st.vnc_types_at
COALESCE(st.vnc_reason, '') AS vnc_reason, st.vnc_types_at,
COALESCE(st.vnc_method, 0) AS vnc_method
FROM devices d
LEFT JOIN switches s ON s.id = d.switch_id AND s.deleted_at IS NULL
LEFT JOIN credentials c ON c.id = d.credential_id AND c.deleted_at IS NULL
+9
View File
@@ -540,6 +540,15 @@ def merke_verfahren(
)
def merke_verfahren_das_traegt(
conn: sqlite3.Connection, device_id: int, verfahren: int
) -> None:
"""Haelt fest, womit die letzte Sitzung wirklich zustande kam."""
conn.execute(
"UPDATE device_status SET vnc_method=? WHERE device_id=?", (int(verfahren), device_id)
)
def run_sweep(
*,
database: Any,
+4
View File
@@ -64,6 +64,9 @@ class Zugang:
#: Der Text, mit dem der Server die Verbindung abgelehnt hat -- er nennt
#: dann kein Verfahren, sondern eine 0 und einen Grund.
absage: str = ""
#: Das Verfahren, mit dem zuletzt wirklich eine Sitzung zustande kam
#: (0: noch keine). Damit faengt der naechste Versuch an.
bewaehrt: int = 0
@property
def browsertauglich(self) -> bool:
@@ -350,6 +353,7 @@ def zugang_gemerkt(device: Mapping[str, Any], *, port: int = STANDARD_PORT) -> Z
int(teil) for teil in gespeichert.split(",") if teil.strip().isdigit()
),
absage=str(device.get("vnc_reason") or ""),
bewaehrt=int(device.get("vnc_method") or 0),
)
@@ -102,11 +102,25 @@
<div class="tile__actions">
{% if tile.vnc %}
{# Die Sitzung laeuft in TESM selbst: ein Klick, kein
Viewer auf dem Arbeitsplatz, keine Datei. #}
<a class="btn btn--sm" href="{{ url_for('screen.screen', device_id=tile.id) }}"
title="Bildschirm im Browser öffnen">
{{ icon('monitor', size=14) }} VNC
</a>
Viewer auf dem Arbeitsplatz, keine Datei.
Mit frischer Passwortbestätigung öffnet sie sich hier
im Fenster -- man bleibt bei der Übersicht und sieht
die anderen Geräte weiter. Ohne sie führt der Knopf
auf die Geräteseite, wo danach gefragt wird. #}
{% if vnc_fenster %}
<button class="btn btn--sm" type="button"
data-vnc-oeffnen="{{ tile.vnc_quelle }}"
data-vnc-name="{{ tile.name }}"
title="Bildschirm im Fenster öffnen">
{{ icon('monitor', size=14) }} VNC
</button>
{% else %}
<a class="btn btn--sm" href="{{ url_for('screen.screen', device_id=tile.id) }}"
title="Bildschirm im Browser öffnen">
{{ icon('monitor', size=14) }} VNC
</a>
{% endif %}
{% else %}
<span class="tile__spacer" aria-hidden="true"></span>
{% endif %}
@@ -0,0 +1,40 @@
{# Die Bildschirmfläche -- einmal geschrieben, an zwei Orten benutzt: auf der
Geräteseite und im Fenster der Statusübersicht.
Die Teile finden sich über `data-rolle`, nicht über Kennungen: im Fenster
gäbe es sie sonst zweimal auf derselben Seite, und die zweite wäre die, die
niemand findet. #}
{% macro vnc_flaeche(quelle='') %}
<div class="screen" {% if quelle %}data-sitzung="{{ quelle }}"{% endif %}>
<div data-rolle="leinwand" class="screen__canvas" hidden></div>
</div>
{% endmacro %}
{% macro vnc_status() %}
<div data-rolle="status" class="screen__status">Wird vorbereitet …</div>
{% endmacro %}
{% macro vnc_zugangsdaten() %}
<form data-rolle="zugangsdaten" class="row row--tight" hidden onsubmit="return false">
<label class="field">
<span class="field__label">Benutzer</span>
<input class="input" type="text" data-rolle="benutzer" autocomplete="off">
</label>
<label class="field">
<span class="field__label">Passwort</span>
<input class="input" type="password" data-rolle="passwort" autocomplete="off">
</label>
<button class="btn btn--primary" type="button" data-rolle="anmelden">Anmelden</button>
</form>
{% endmacro %}
{# Tasten, die der Arbeitsplatz selbst abfängt und die deshalb einen Knopf
brauchen: Strg+Alt+Entf schaltet der Browser gar nicht erst durch, Alt+F4
schlösse das Fenster hier statt das Fenster dort. #}
{% macro vnc_tasten() %}
<button class="btn btn--sm" type="button" data-rolle="taste-cad" hidden
title="Strg+Alt+Entf an das Gerät senden">Strg+Alt+Entf</button>
<button class="btn btn--sm" type="button" data-rolle="taste-altf4" hidden
title="Alt+F4 an das Gerät senden – schließt dort das oberste Fenster">Alt+F4</button>
<button class="btn btn--sm" type="button" data-rolle="trennen" hidden>Trennen</button>
{% endmacro %}
@@ -1,5 +1,6 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import search_box %}
{% from "tesm_core/macros.html" import search_box, modal %}
{% from "tesm/_vnc_flaeche.html" import vnc_flaeche, vnc_status, vnc_zugangsdaten, vnc_tasten %}
{% block page_title %}Statusübersicht{% endblock %}
{% block page_sub %}
@@ -19,4 +20,27 @@
data-refresh-interval="20000">
{% include "tesm/_dashboard_tiles.html" %}
</div>
{% if vnc_fenster %}
{# Ein Fenster für alle Kacheln: welches Gerät gemeint ist, bringt der Knopf
beim Öffnen mit. Ein Dialog je Kachel wäre dieselbe Sache dutzendfach im
Quelltext -- und dutzendfach eine Sitzung, die niemand aufräumt. #}
{% call modal('vnc-fenster', 'Bildschirm', 'Tastatur und Maus gehen an das Gerät. Die Zwischenablage bleibt getrennt.', 'wide') %}
<div class="modal__body stack" data-rolle="behaelter">
<div class="row row--tight">
<strong data-rolle="titel" class="flex-1"></strong>
{{ vnc_tasten() }}
</div>
{{ vnc_status() }}
{{ vnc_zugangsdaten() }}
{{ vnc_flaeche() }}
</div>
{% endcall %}
{% endif %}
{% endblock %}
{% block scripts %}
{% if vnc_fenster %}
<script type="module" src="{{ asset_url('js/screen.js') }}" nonce="{{ csp_nonce }}"></script>
{% endif %}
{% endblock %}
+6 -34
View File
@@ -1,5 +1,6 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import alert, pill %}
{% from "tesm/_vnc_flaeche.html" import vnc_flaeche, vnc_status, vnc_zugangsdaten, vnc_tasten %}
{% block page_title %}Bildschirm: {{ device.name }}{% endblock %}
{% block page_sub %}{{ zugang.ziel }}:{{ zugang.port }}{% endblock %}
@@ -13,13 +14,6 @@
{% endblock %}
{% block content %}
{# Die Zugangsdaten stehen in einem eigenen JSON-Block statt in einem
data-Attribut: ein Attribut steht im Quelltext der Seite zwischen der
Beschriftung und wird beim Kopieren einer Zeile mitgenommen. Beides ist
derselbe Inhalt -- der Unterschied ist, wie leicht er einem versehentlich
unter die Augen kommt. #}
<script type="application/json" id="screen-credentials-data">{{ vorgaben | tojson }}</script>
<div class="stack">
{% if zugang.hinderungsgrund %}
{{ alert(zugang.hinderungsgrund, 'warning') }}
@@ -49,9 +43,7 @@
</div>
</div>
<div class="row row--tight">
<button class="btn btn--sm" type="button" id="screen-cad" hidden
title="Strg+Alt+Entf an das Gerät senden">Strg+Alt+Entf</button>
<button class="btn btn--sm" type="button" id="screen-disconnect" hidden>Trennen</button>
{{ vnc_tasten() }}
{# Ausdrücklich ein Knopf und keine Nebenwirkung des Seitenaufbaus: die
Prüfung bricht eine Protokollverhandlung ab, und RealVNC zählt das.
Wer sucht, drückt ihn; wer nur arbeiten will, merkt nichts davon. #}
@@ -70,30 +62,10 @@
</div>
</div>
<div class="card__body stack">
<div id="screen-status" class="screen__status">Wird vorbereitet …</div>
<form id="screen-credentials" class="row row--tight" hidden onsubmit="return false">
<label class="field">
<span class="field__label">Benutzer</span>
<input class="input" type="text" id="screen-username" autocomplete="off"
value="{{ vorgaben.username }}">
</label>
<label class="field">
<span class="field__label">Passwort</span>
<input class="input" type="password" id="screen-password" autocomplete="off">
</label>
<button class="btn btn--primary" type="button" id="screen-send">Anmelden</button>
</form>
<div id="screen" class="screen"
data-socket="{{ websocket_path }}"
data-ziel="{{ zugang.ziel }}:{{ zugang.port }}"
data-verfahren="{{ zugang.verfahren | join(',') }}"
data-blockiert="{{ '1' if zugang.hinderungsgrund else '0' }}"
data-grund="{{ zugang.hinderungsgrund }}">
<div id="screen-canvas" class="screen__canvas" hidden></div>
</div>
<div class="card__body stack" id="screen" data-sitzung="{{ quelle }}">
{{ vnc_status() }}
{{ vnc_zugangsdaten() }}
{{ vnc_flaeche() }}
{% if not darf_geheimnis and device.credential_id %}
<p class="fs-xs faint">