Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4275c84a6c | ||
|
|
4f4310bfe4 | ||
|
|
1ad2f3f9a5 | ||
|
|
9d5eac1db2 |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm"
|
name = "tesm"
|
||||||
version = "2.8.6"
|
version = "2.8.10"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
|||||||
maintenance_active=_maintenance_active(),
|
maintenance_active=_maintenance_active(),
|
||||||
darf=current_user.has,
|
darf=current_user.has,
|
||||||
),
|
),
|
||||||
|
# Aus dem letzten Durchlauf, nicht frisch gefragt: 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": bool(row["vnc_open"])
|
||||||
|
and current_user.has("device_vnc.execute"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
tiles.append(entry)
|
tiles.append(entry)
|
||||||
|
|||||||
@@ -11,14 +11,23 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
from flask import (
|
||||||
|
Blueprint,
|
||||||
|
Response,
|
||||||
|
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
|
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
|
||||||
|
|
||||||
from ..services import inventory, monitor, restart, ssh
|
from ..services import inventory, monitor, restart, ssh, vnc
|
||||||
|
|
||||||
bp = Blueprint("devices", __name__, url_prefix="/monitoring/clients")
|
bp = Blueprint("devices", __name__, url_prefix="/monitoring/clients")
|
||||||
|
|
||||||
@@ -360,6 +369,69 @@ def detail(device_id: int):
|
|||||||
host_keys=host_keys,
|
host_keys=host_keys,
|
||||||
method_labels=monitor.METHOD_LABELS,
|
method_labels=monitor.METHOD_LABELS,
|
||||||
terminal_enabled=bool(extension.settings.get(conn, "terminal_clients_enabled")),
|
terminal_enabled=bool(extension.settings.get(conn, "terminal_clients_enabled")),
|
||||||
|
vnc=_vnc_zugang(conn, device),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _vnc_zugang(conn: Any, device: dict[str, Any]) -> vnc.Zugang | None:
|
||||||
|
"""Der Bildschirmzugriff dieses Clients -- frisch gefragt, nicht geraten.
|
||||||
|
|
||||||
|
Die Kacheln der Statusuebersicht nehmen den Stand aus dem letzten
|
||||||
|
Ueberwachungsdurchlauf; hier geht es um **ein** Geraet, also fragt die Seite
|
||||||
|
selbst nach (ein halbe Sekunde Zeitlimit, und nur wenn der Client gerade
|
||||||
|
erreichbar ist). Das Ergebnis wird gleich festgehalten -- dann stimmt auch
|
||||||
|
die Kachel, ohne auf den naechsten Durchlauf zu warten.
|
||||||
|
"""
|
||||||
|
extension = core()
|
||||||
|
if not current_user.has("device_vnc.execute"):
|
||||||
|
return None
|
||||||
|
if not extension.settings.get(conn, "vnc_enabled"):
|
||||||
|
return None
|
||||||
|
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")
|
||||||
|
zugang = vnc.zugang(device, port=port, pruefen=online)
|
||||||
|
if online:
|
||||||
|
with transaction():
|
||||||
|
monitor.merke_vnc(conn, int(device["id"]), zugang.erreichbar)
|
||||||
|
return zugang
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/<int:device_id>/vnc")
|
||||||
|
@permission_required("device_vnc.execute")
|
||||||
|
def vnc_connection(device_id: int):
|
||||||
|
"""Die Verbindungsdatei -- drei Zeilen, sonst nichts.
|
||||||
|
|
||||||
|
Kein Passwort, kein Benutzername, keine Verschluesselungsvorgabe: was beim
|
||||||
|
Verbinden verlangt wird, entscheidet der VNC-Server auf dem Geraet. Die
|
||||||
|
Datei traegt nur, wohin es geht und wie die Verbindung im Viewer heisst.
|
||||||
|
"""
|
||||||
|
conn = db()
|
||||||
|
device = inventory.get_device(conn, device_id)
|
||||||
|
if device is None:
|
||||||
|
abort(404)
|
||||||
|
extension = core()
|
||||||
|
if not extension.settings.get(conn, "vnc_enabled"):
|
||||||
|
abort(404)
|
||||||
|
port = int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT)
|
||||||
|
ziel = vnc.ziel(device)
|
||||||
|
if not ziel:
|
||||||
|
abort(404)
|
||||||
|
with transaction():
|
||||||
|
_audit(
|
||||||
|
conn,
|
||||||
|
"device.vnc_opened",
|
||||||
|
str(device["name"]),
|
||||||
|
f"Verbindungsdatei für {ziel}:{port}",
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
vnc.verbindungsdatei(device, port=port),
|
||||||
|
mimetype="application/octet-stream",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{vnc.dateiname(device)}"',
|
||||||
|
# Eine Verbindungsdatei ist kein Dokument, das ein Proxy aufheben
|
||||||
|
# soll -- und sie ist billig genug, sie jedes Mal neu zu erzeugen.
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ from tesm_core.auth.service import client_ip
|
|||||||
from tesm_core.extension import core, db, transaction
|
from tesm_core.extension import core, db, transaction
|
||||||
from tesm_core.licensing_client import LicenseError
|
from tesm_core.licensing_client import LicenseError
|
||||||
|
|
||||||
|
import tesm_licensing as lic
|
||||||
|
|
||||||
bp = Blueprint("settings", __name__, url_prefix="/administration")
|
bp = Blueprint("settings", __name__, url_prefix="/administration")
|
||||||
|
|
||||||
|
|
||||||
@@ -46,6 +48,28 @@ def _license_hint(feature: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _module_rows(conn: Any) -> list[dict[str, Any]]:
|
||||||
|
"""Die drei Module mit ihrem Zustand -- lizenziert, benutzt.
|
||||||
|
|
||||||
|
Zwei verschiedene Fragen, die die Seite auseinanderhalten muss: was die
|
||||||
|
Lizenz erlaubt (dagegen hilft kein Schalter) und was dieses Haus benutzen
|
||||||
|
will (dafuer ist der Schalter da).
|
||||||
|
"""
|
||||||
|
manager = core().license
|
||||||
|
if manager is None:
|
||||||
|
return []
|
||||||
|
state = manager.reload(conn)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"key": name,
|
||||||
|
"label": lic.MODULE_LABELS.get(name, name),
|
||||||
|
"licensed": name in state.modules,
|
||||||
|
"enabled": name not in state.modules_off,
|
||||||
|
}
|
||||||
|
for name in lic.ALL_MODULES
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _current_timezone() -> str:
|
def _current_timezone() -> str:
|
||||||
sysops = core().sysops
|
sysops = core().sysops
|
||||||
if sysops is None:
|
if sysops is None:
|
||||||
@@ -79,6 +103,8 @@ def system():
|
|||||||
"monitor_failures_before_restart",
|
"monitor_failures_before_restart",
|
||||||
"monitor_auto_restart",
|
"monitor_auto_restart",
|
||||||
"dashboard_public",
|
"dashboard_public",
|
||||||
|
"vnc_enabled",
|
||||||
|
"vnc_port",
|
||||||
"terminal_switches_enabled",
|
"terminal_switches_enabled",
|
||||||
"terminal_clients_enabled",
|
"terminal_clients_enabled",
|
||||||
"terminal_idle_timeout",
|
"terminal_idle_timeout",
|
||||||
@@ -94,11 +120,16 @@ def system():
|
|||||||
hostname=(sysops.read_command(["hostname"]).stdout.strip() if sysops else ""),
|
hostname=(sysops.read_command(["hostname"]).stdout.strip() if sysops else ""),
|
||||||
timezone=_current_timezone(),
|
timezone=_current_timezone(),
|
||||||
timezones=_timezone_choices(),
|
timezones=_timezone_choices(),
|
||||||
nav_items=extension.nav.items,
|
# Nicht ``nav_items``: dieser Name gehoert dem Seitenrahmen. Wer ihn hier
|
||||||
|
# belegt, ueberschreibt die gefilterte Navigation aus dem Kontext --
|
||||||
|
# und die Seitenleiste zeigte dann jeden Punkt, auch den, den dieser
|
||||||
|
# Benutzer nicht darf und den die Lizenz nicht freischaltet.
|
||||||
|
nav_baum=extension.nav.items,
|
||||||
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
|
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
|
||||||
nav_child_order=extension.settings.get(conn, "nav_child_order") or {},
|
nav_child_order=extension.settings.get(conn, "nav_child_order") or {},
|
||||||
license_hint=_license_hint("monitor_interval"),
|
license_hint=_license_hint("monitor_interval"),
|
||||||
sysops_available=bool(sysops and sysops.available),
|
sysops_available=bool(sysops and sysops.available),
|
||||||
|
module_rows=_module_rows(conn),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -114,6 +145,8 @@ def system_save():
|
|||||||
"monitor_failures_before_restart",
|
"monitor_failures_before_restart",
|
||||||
"monitor_auto_restart",
|
"monitor_auto_restart",
|
||||||
"dashboard_public",
|
"dashboard_public",
|
||||||
|
"vnc_enabled",
|
||||||
|
"vnc_port",
|
||||||
"terminal_switches_enabled",
|
"terminal_switches_enabled",
|
||||||
"terminal_clients_enabled",
|
"terminal_clients_enabled",
|
||||||
"terminal_idle_timeout",
|
"terminal_idle_timeout",
|
||||||
@@ -167,6 +200,56 @@ def system_save():
|
|||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/system/modules")
|
||||||
|
@permission_required("settings_system.write")
|
||||||
|
def modules_save():
|
||||||
|
"""Module ein- und ausschalten.
|
||||||
|
|
||||||
|
Abgeschaltet heisst wirklich abgeschaltet: der Punkt verschwindet aus der
|
||||||
|
Navigation, und die Routen antworten mit 404 -- dieselbe Sperre, die auch
|
||||||
|
eine fehlende Lizenz zieht. Daten bleiben unangetastet; wer das Modul
|
||||||
|
wieder einschaltet, findet alles vor wie zuvor.
|
||||||
|
"""
|
||||||
|
extension = core()
|
||||||
|
manager = extension.license
|
||||||
|
if manager is None:
|
||||||
|
flash("Diese Installation nutzt keine Lizenzierung.", "info")
|
||||||
|
return redirect(url_for("settings.system"))
|
||||||
|
|
||||||
|
aus = [name for name in lic.ALL_MODULES if request.form.get(f"module_{name}") != "1"]
|
||||||
|
with transaction() as conn:
|
||||||
|
eingeschaltet, abgeschaltet = manager.module_abschalten(
|
||||||
|
conn, aus, actor=current_user.username
|
||||||
|
)
|
||||||
|
if eingeschaltet or abgeschaltet:
|
||||||
|
teile = []
|
||||||
|
if abgeschaltet:
|
||||||
|
teile.append(
|
||||||
|
"abgeschaltet: "
|
||||||
|
+ ", ".join(lic.MODULE_LABELS.get(n, n) for n in sorted(abgeschaltet))
|
||||||
|
)
|
||||||
|
if eingeschaltet:
|
||||||
|
teile.append(
|
||||||
|
"eingeschaltet: "
|
||||||
|
+ ", ".join(lic.MODULE_LABELS.get(n, n) for n in sorted(eingeschaltet))
|
||||||
|
)
|
||||||
|
audit.log(
|
||||||
|
conn,
|
||||||
|
actor=current_user.username,
|
||||||
|
actor_id=current_user.id,
|
||||||
|
action="settings.modules_changed",
|
||||||
|
detail="; ".join(teile),
|
||||||
|
ip=client_ip(),
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
|
|
||||||
|
if eingeschaltet or abgeschaltet:
|
||||||
|
flash("Module gespeichert.", "success")
|
||||||
|
else:
|
||||||
|
flash("Keine Änderungen.", "info")
|
||||||
|
return redirect(url_for("settings.system"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/system/hostname")
|
@bp.post("/system/hostname")
|
||||||
@permission_required("settings_system.execute")
|
@permission_required("settings_system.execute")
|
||||||
def set_hostname():
|
def set_hostname():
|
||||||
@@ -226,7 +309,7 @@ def run_check_now():
|
|||||||
extension = core()
|
extension = core()
|
||||||
|
|
||||||
def worker() -> None:
|
def worker() -> None:
|
||||||
from ..services import inventory, monitor
|
from ..services import inventory, monitor, vnc
|
||||||
|
|
||||||
allowed = extension.license is None or extension.license.feature_allowed(
|
allowed = extension.license is None or extension.license.feature_allowed(
|
||||||
"auto_poe_restart"
|
"auto_poe_restart"
|
||||||
@@ -236,6 +319,7 @@ def run_check_now():
|
|||||||
live=extension.registry["live_log"],
|
live=extension.registry["live_log"],
|
||||||
keystore=extension.keystore,
|
keystore=extension.keystore,
|
||||||
license_allows_auto_restart=allowed,
|
license_allows_auto_restart=allowed,
|
||||||
|
vnc_port=vnc.port_aus_einstellungen(extension),
|
||||||
)
|
)
|
||||||
|
|
||||||
threading.Thread(target=worker, name="manual-sweep", daemon=True).start()
|
threading.Thread(target=worker, name="manual-sweep", daemon=True).start()
|
||||||
|
|||||||
@@ -69,13 +69,41 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
|
|||||||
)
|
)
|
||||||
user_id = int(cursor.lastrowid or 0)
|
user_id = int(cursor.lastrowid or 0)
|
||||||
action = "angelegt"
|
action = "angelegt"
|
||||||
|
# Der Vollzugriff gehoert dazu. Seit 2.8.0 gibt das
|
||||||
|
# Administratorkennzeichen den Verwaltungsbereich und sonst
|
||||||
|
# nichts; Funktionsrechte kommen ueber Gruppen. Ein Konto, das
|
||||||
|
# jemand an der Kommandozeile anlegt, ist aber der Notausgang --
|
||||||
|
# es entsteht, wenn sonst niemand mehr hineinkommt, und dann
|
||||||
|
# nuetzt eine Verwaltung ohne Geraete, Freigaben und Protokolle
|
||||||
|
# wenig. Wer das nicht will, nimmt die Gruppe in einem Klick
|
||||||
|
# wieder weg.
|
||||||
|
from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
|
||||||
|
|
||||||
|
gruppe = rbac.funktionsgruppe_anlegen(
|
||||||
|
conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF
|
||||||
|
)
|
||||||
|
# Liegt sie im Papierkorb, kommt sie zurueck. Sonst haette dieser
|
||||||
|
# Aufruf nichts bewirkt: das Konto waere in einer geloeschten
|
||||||
|
# Gruppe -- und das faellt genau dann auf, wenn man es am
|
||||||
|
# wenigsten gebrauchen kann.
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE groups SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL",
|
||||||
|
(gruppe,),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
|
||||||
|
(user_id, gruppe),
|
||||||
|
)
|
||||||
audit.log(
|
audit.log(
|
||||||
conn,
|
conn,
|
||||||
actor="cli",
|
actor="cli",
|
||||||
actor_id=user_id,
|
actor_id=user_id,
|
||||||
action="user.admin_created",
|
action="user.admin_created",
|
||||||
target=username,
|
target=username,
|
||||||
detail=f"über die Kommandozeile {action}",
|
detail=(
|
||||||
|
f"über die Kommandozeile {action}, Gruppe "
|
||||||
|
f"{FUNKTIONEN_VOLLZUGRIFF.name} zugewiesen"
|
||||||
|
),
|
||||||
severity="warning",
|
severity="warning",
|
||||||
)
|
)
|
||||||
print(f"Administrator {username!r} {action}.")
|
print(f"Administrator {username!r} {action}.")
|
||||||
@@ -191,7 +219,7 @@ def cmd_restore_signatures(_args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
def cmd_check_now(_args: argparse.Namespace) -> int:
|
def cmd_check_now(_args: argparse.Namespace) -> int:
|
||||||
app, extension = _app_and_core()
|
app, extension = _app_and_core()
|
||||||
from .services import inventory, monitor
|
from .services import inventory, monitor, vnc
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
allowed = extension.license is None or extension.license.feature_allowed(
|
allowed = extension.license is None or extension.license.feature_allowed(
|
||||||
@@ -202,6 +230,7 @@ def cmd_check_now(_args: argparse.Namespace) -> int:
|
|||||||
live=extension.registry["live_log"],
|
live=extension.registry["live_log"],
|
||||||
keystore=extension.keystore,
|
keystore=extension.keystore,
|
||||||
license_allows_auto_restart=allowed,
|
license_allows_auto_restart=allowed,
|
||||||
|
vnc_port=vnc.port_aus_einstellungen(extension),
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f"{report.checked} geprüft, {report.online} online, {report.offline} offline, "
|
f"{report.checked} geprüft, {report.online} online, {report.offline} offline, "
|
||||||
|
|||||||
@@ -78,6 +78,26 @@ DEVICE_RESTART_RPC = Resource(
|
|||||||
action_hints={A.EXECUTE: "Neustartauftrag anlegen (wie 'shutdown /m')."},
|
action_hints={A.EXECUTE: "Neustartauftrag anlegen (wie 'shutdown /m')."},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: Bildschirmzugriff ist **nicht** dasselbe wie die Konsole, auch wenn beides
|
||||||
|
#: "auf das Geraet" fuehrt: hier sieht jemand den Bildschirm, wie er ist --
|
||||||
|
#: samt dem, was ein Mensch dort gerade offen hat. Das ist eine eigene
|
||||||
|
#: Entscheidung, also eine eigene Zeile. Sie wird **niemandem** mitgegeben:
|
||||||
|
#: keine Migration setzt sie, auch nicht bei denen, die schon eine Konsole
|
||||||
|
#: oeffnen duerfen. Wer sie haben soll, bekommt sie in der Matrix -- einmal,
|
||||||
|
#: sichtbar, mit Zeile im Aenderungsprotokoll.
|
||||||
|
DEVICE_VNC = Resource(
|
||||||
|
key="device_vnc",
|
||||||
|
parent="devices",
|
||||||
|
label="Clients: Bildschirm (VNC)",
|
||||||
|
actions=(A.READ, A.EXECUTE),
|
||||||
|
hint="Verbindungsdatei bzw. vnc://-Adresse für den Bildschirm des Clients",
|
||||||
|
action_hints={
|
||||||
|
A.READ: "Den Knopf sehen, wo ein VNC-Server antwortet.",
|
||||||
|
A.EXECUTE: "Verbindung öffnen. Die Datei enthält nur Ziel und Namen – "
|
||||||
|
"was beim Verbinden verlangt wird, entscheidet der VNC-Server auf dem Gerät.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
#: Die Client-Konsole lief unter ``maintenance.execute`` mit -- demselben Recht
|
#: Die Client-Konsole lief unter ``maintenance.execute`` mit -- demselben Recht
|
||||||
#: wie die Paketaktualisierung. Eine Sitzung auf einem Geraet ist aber etwas
|
#: wie die Paketaktualisierung. Eine Sitzung auf einem Geraet ist aber etwas
|
||||||
#: anderes als ein Auftrag, den der Server abarbeitet: hier tippt ein Mensch.
|
#: anderes als ein Auftrag, den der Server abarbeitet: hier tippt ein Mensch.
|
||||||
@@ -316,6 +336,7 @@ MONITORING_AREA = Area(
|
|||||||
DEVICE_RESTART_SSH,
|
DEVICE_RESTART_SSH,
|
||||||
DEVICE_RESTART_RPC,
|
DEVICE_RESTART_RPC,
|
||||||
DEVICE_CONSOLE,
|
DEVICE_CONSOLE,
|
||||||
|
DEVICE_VNC,
|
||||||
SWITCHES,
|
SWITCHES,
|
||||||
SWITCH_CONSOLE,
|
SWITCH_CONSOLE,
|
||||||
HOSTKEYS,
|
HOSTKEYS,
|
||||||
@@ -388,6 +409,15 @@ FUNKTIONEN_VOLLZUGRIFF = RolePreset(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: Die Gruppen, die eine frische Installation mitbringt -- drei, nicht sieben.
|
||||||
|
#:
|
||||||
|
#: Vorher standen hier zusaetzlich Betrieb, Support, Netzwerk und Revision.
|
||||||
|
#: Gut gemeint, aber sie beantworten eine Frage, die jeder Betrieb anders
|
||||||
|
#: beantwortet: welche Rolle es gibt, weiss der Kunde, nicht die Anwendung.
|
||||||
|
#: Was bleibt, sind die drei, ohne die man nicht anfangen kann -- eine
|
||||||
|
#: Standardgruppe fuer jeden neuen Nutzer, eine fuer die Verwaltung und eine,
|
||||||
|
#: die alle Funktionen buendelt. Weitere legt man in der Matrix an, und die
|
||||||
|
#: Matrix zeigt seit 2.8.0 je Funktion eine Zeile.
|
||||||
ROLE_PRESETS: tuple[RolePreset, ...] = (
|
ROLE_PRESETS: tuple[RolePreset, ...] = (
|
||||||
RolePreset(
|
RolePreset(
|
||||||
key="viewer",
|
key="viewer",
|
||||||
@@ -400,78 +430,6 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
|
|||||||
),
|
),
|
||||||
is_default=True,
|
is_default=True,
|
||||||
),
|
),
|
||||||
RolePreset(
|
|
||||||
key="operations",
|
|
||||||
name="Betrieb",
|
|
||||||
description="Geräte und Switche pflegen, Neustarts auslösen, Wartung fahren.",
|
|
||||||
grants=(
|
|
||||||
("devices", "*"),
|
|
||||||
("device_restart_poe", "*"),
|
|
||||||
("device_restart_ssh", "*"),
|
|
||||||
("device_restart_rpc", "*"),
|
|
||||||
("device_console", "*"),
|
|
||||||
("switches", (A.READ, A.CREATE, A.WRITE)),
|
|
||||||
("switch_console", "*"),
|
|
||||||
("hostkeys", "*"),
|
|
||||||
("credentials", (A.READ,)),
|
|
||||||
("servicing_update", "*"),
|
|
||||||
("servicing_reboot", "*"),
|
|
||||||
("servicing_tool", "*"),
|
|
||||||
("logs_live", (A.READ,)),
|
|
||||||
("logs_history", (A.READ,)),
|
|
||||||
("trash", (A.READ, A.WRITE)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RolePreset(
|
|
||||||
key="support",
|
|
||||||
name="Support",
|
|
||||||
description="Erste Hilfe am Telefon: Status sehen, PoE-Neustart auslösen.",
|
|
||||||
grants=(
|
|
||||||
("devices", (A.READ,)),
|
|
||||||
# Nur der PoE-Weg: der wirkt auch bei einem haengenden Geraet und
|
|
||||||
# unterbricht nichts Laufendes. SSH und RPC fahren ein arbeitendes
|
|
||||||
# System herunter -- das gehoert nicht in die erste Hilfe.
|
|
||||||
("device_restart_poe", "*"),
|
|
||||||
("switches", (A.READ,)),
|
|
||||||
("logs_live", (A.READ,)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RolePreset(
|
|
||||||
key="network",
|
|
||||||
name="Netzwerk",
|
|
||||||
description="DHCP und Host-Netzwerkeinstellungen.",
|
|
||||||
grants=(
|
|
||||||
("dhcp_subnets", "*"),
|
|
||||||
("dhcp_options", "*"),
|
|
||||||
("dhcp_reservations", "*"),
|
|
||||||
("dhcp_config", "*"),
|
|
||||||
("dhcp_service", "*"),
|
|
||||||
("settings_network", "*"),
|
|
||||||
("logs_dhcp", (A.READ,)),
|
|
||||||
("devices", (A.READ,)),
|
|
||||||
("switches", (A.READ,)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RolePreset(
|
|
||||||
key="auditor",
|
|
||||||
name="Revision",
|
|
||||||
description="Nur lesen – inklusive Änderungsprotokoll und Diagnose.",
|
|
||||||
grants=(
|
|
||||||
("devices", (A.READ,)),
|
|
||||||
("switches", (A.READ,)),
|
|
||||||
("credentials", (A.READ,)),
|
|
||||||
("dhcp_subnets", (A.READ,)),
|
|
||||||
("dhcp_options", (A.READ,)),
|
|
||||||
("dhcp_reservations", (A.READ,)),
|
|
||||||
("users", (A.READ,)),
|
|
||||||
("groups", (A.READ,)),
|
|
||||||
("logs_live", (A.READ,)),
|
|
||||||
("logs_history", (A.READ, A.EXPORT)),
|
|
||||||
("logs_audit", (A.READ, A.EXPORT)),
|
|
||||||
("diagnostics", (A.READ,)),
|
|
||||||
("trash", (A.READ,)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RolePreset(
|
RolePreset(
|
||||||
key="administration",
|
key="administration",
|
||||||
name="Administration",
|
name="Administration",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ Frische Zeitlinie ab Version 1 -- kein Nachbau der 35 gewachsenen
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from tesm_core.migrations import Migration
|
from tesm_core.migrations import Migration
|
||||||
|
|
||||||
V1 = """
|
V1 = """
|
||||||
@@ -393,6 +395,136 @@ def uebergang_funktionsgruppe(conn) -> None: # type: ignore[no-untyped-def]
|
|||||||
rbac.funktionsgruppe_fuer_administratoren(conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF)
|
rbac.funktionsgruppe_fuer_administratoren(conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF)
|
||||||
|
|
||||||
|
|
||||||
|
#: Die vier Vorlagen, die es bis 2.8.6 gab und ab 2.8.7 nicht mehr.
|
||||||
|
#:
|
||||||
|
#: Stehen hier als eingefrorene Kopie, weil eine Migration ihre eigenen Daten
|
||||||
|
#: mitbringen muss: in ``permissions.py`` sind sie weg, und trotzdem muss
|
||||||
|
#: dieser Schritt noch erkennen koennen, ob eine vorgefundene Gruppe wirklich
|
||||||
|
#: unveraendert die ausgelieferte ist.
|
||||||
|
ABGELEGTE_VORLAGEN: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...] = (
|
||||||
|
(
|
||||||
|
"operations",
|
||||||
|
"Betrieb",
|
||||||
|
(
|
||||||
|
("devices", "*"),
|
||||||
|
("device_restart_poe", "*"),
|
||||||
|
("device_restart_ssh", "*"),
|
||||||
|
("device_restart_rpc", "*"),
|
||||||
|
("device_console", "*"),
|
||||||
|
("switches", ("read", "create", "write")),
|
||||||
|
("switch_console", "*"),
|
||||||
|
("hostkeys", "*"),
|
||||||
|
("credentials", ("read",)),
|
||||||
|
("servicing_update", "*"),
|
||||||
|
("servicing_reboot", "*"),
|
||||||
|
("servicing_tool", "*"),
|
||||||
|
("logs_live", ("read",)),
|
||||||
|
("logs_history", ("read",)),
|
||||||
|
("trash", ("read", "write")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"support",
|
||||||
|
"Support",
|
||||||
|
(
|
||||||
|
("devices", ("read",)),
|
||||||
|
("device_restart_poe", "*"),
|
||||||
|
("switches", ("read",)),
|
||||||
|
("logs_live", ("read",)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"network",
|
||||||
|
"Netzwerk",
|
||||||
|
(
|
||||||
|
("dhcp_subnets", "*"),
|
||||||
|
("dhcp_options", "*"),
|
||||||
|
("dhcp_reservations", "*"),
|
||||||
|
("dhcp_config", "*"),
|
||||||
|
("dhcp_service", "*"),
|
||||||
|
("settings_network", "*"),
|
||||||
|
("logs_dhcp", ("read",)),
|
||||||
|
("devices", ("read",)),
|
||||||
|
("switches", ("read",)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"auditor",
|
||||||
|
"Revision",
|
||||||
|
(
|
||||||
|
("devices", ("read",)),
|
||||||
|
("switches", ("read",)),
|
||||||
|
("credentials", ("read",)),
|
||||||
|
("dhcp_subnets", ("read",)),
|
||||||
|
("dhcp_options", ("read",)),
|
||||||
|
("dhcp_reservations", ("read",)),
|
||||||
|
("users", ("read",)),
|
||||||
|
("groups", ("read",)),
|
||||||
|
("logs_live", ("read",)),
|
||||||
|
("logs_history", ("read", "export")),
|
||||||
|
("logs_audit", ("read", "export")),
|
||||||
|
("diagnostics", ("read",)),
|
||||||
|
("trash", ("read",)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def eingefrorene_vorlagen(
|
||||||
|
beschreibung: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...],
|
||||||
|
) -> list[Any]:
|
||||||
|
"""Baut aus der Kopie oben wieder ``RolePreset``-Objekte.
|
||||||
|
|
||||||
|
Die Aktionen stehen dort als Text und nicht als ``Action``, damit diese
|
||||||
|
Migration auch dann noch lesbar bleibt, wenn das Enum sich weiterentwickelt.
|
||||||
|
"""
|
||||||
|
from tesm_core.rbac.model import Action, RolePreset
|
||||||
|
|
||||||
|
vorlagen = []
|
||||||
|
for key, name, grants in beschreibung:
|
||||||
|
gewandelt = tuple(
|
||||||
|
(ressource, aktionen if aktionen == "*" else tuple(Action(a) for a in aktionen))
|
||||||
|
for ressource, aktionen in grants
|
||||||
|
)
|
||||||
|
vorlagen.append(RolePreset(key=key, name=name, description="", grants=gewandelt))
|
||||||
|
return vorlagen
|
||||||
|
|
||||||
|
|
||||||
|
# Version 10 ----------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Wo antwortet ein VNC-Server? Die Frage beantwortet der Ueberwachungs-
|
||||||
|
# durchlauf mit, der ohnehin jedes Geraet anfasst -- einmal je Intervall statt
|
||||||
|
# einmal je Seitenaufruf. Die Kacheln der Statusuebersicht koennen den Knopf
|
||||||
|
# damit zeigen, ohne dass das Rendern der Seite ein Dutzend Verbindungen
|
||||||
|
# aufbaut.
|
||||||
|
V10 = """
|
||||||
|
ALTER TABLE device_status ADD COLUMN vnc_open INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE device_status ADD COLUMN vnc_checked_at TEXT;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Aus sieben Standardgruppen werden drei -- ohne jemandem etwas wegzunehmen.
|
||||||
|
|
||||||
|
Was eine Anwendung an Rollen mitbringt, ist eine Vermutung darueber, wie
|
||||||
|
ein Betrieb organisiert ist. Sieben davon sind sechs zu viele: wer sie
|
||||||
|
nicht braucht, muss sie trotzdem jedes Mal ueberlesen, und in der
|
||||||
|
Rechtematrix stand fuer jede eine Spalte. Es bleiben "Benutzer",
|
||||||
|
"Administration" und "Funktionen (Vollzugriff)".
|
||||||
|
|
||||||
|
Weggeraeumt wird nur, was unberuehrt ist -- keine Mitglieder, Rechte
|
||||||
|
unveraendert. Alles andere bleibt stehen, wo es steht. Und auch das
|
||||||
|
Weggeraeumte liegt nur im Papierkorb.
|
||||||
|
"""
|
||||||
|
from tesm_core.rbac import service as rbac
|
||||||
|
|
||||||
|
from .permissions import PERMISSIONS
|
||||||
|
|
||||||
|
rbac.unberuehrte_vorlagen_entfernen(
|
||||||
|
conn, PERMISSIONS, eingefrorene_vorlagen(ABGELEGTE_VORLAGEN)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
TESM_MIGRATIONS: tuple[Migration, ...] = (
|
TESM_MIGRATIONS: tuple[Migration, ...] = (
|
||||||
Migration(version=1, name="baseline", sql=V1),
|
Migration(version=1, name="baseline", sql=V1),
|
||||||
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
|
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
|
||||||
@@ -412,4 +544,6 @@ TESM_MIGRATIONS: tuple[Migration, ...] = (
|
|||||||
),
|
),
|
||||||
Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen),
|
Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen),
|
||||||
Migration(version=8, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe),
|
Migration(version=8, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe),
|
||||||
|
Migration(version=9, name="standardgruppen-aufraeumen", fn=uebergang_standardgruppen),
|
||||||
|
Migration(version=10, name="vnc-erreichbarkeit", sql=V10),
|
||||||
)
|
)
|
||||||
@@ -542,7 +542,7 @@ def list_devices(
|
|||||||
WHERE k.host = d.ip AND k.port = d.ssh_port) AS host_key_known,
|
WHERE k.host = d.ip AND k.port = d.ssh_port) AS host_key_known,
|
||||||
COALESCE(st.state,'unknown') AS state, st.latency_ms, st.last_check_at,
|
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.last_ok_at, st.last_change_at, st.last_restart_at, st.restart_count,
|
||||||
st.fail_count
|
st.fail_count, COALESCE(st.vnc_open, 0) AS vnc_open, st.vnc_checked_at
|
||||||
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
|
||||||
|
|||||||
@@ -507,6 +507,14 @@ class SweepReport:
|
|||||||
duration_ms: int = 0
|
duration_ms: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def merke_vnc(conn: sqlite3.Connection, device_id: int, offen: bool) -> None:
|
||||||
|
"""Haelt fest, ob dort gerade ein VNC-Server antwortet."""
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE device_status SET vnc_open=?, vnc_checked_at=? WHERE device_id=?",
|
||||||
|
(1 if offen else 0, _now(), device_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_sweep(
|
def run_sweep(
|
||||||
*,
|
*,
|
||||||
database: Any,
|
database: Any,
|
||||||
@@ -514,13 +522,21 @@ def run_sweep(
|
|||||||
keystore: Any,
|
keystore: Any,
|
||||||
license_allows_auto_restart: bool,
|
license_allows_auto_restart: bool,
|
||||||
failures_before_restart: int = DEFAULT_FAILURES_BEFORE_RESTART,
|
failures_before_restart: int = DEFAULT_FAILURES_BEFORE_RESTART,
|
||||||
|
vnc_port: int = 0,
|
||||||
) -> SweepReport:
|
) -> SweepReport:
|
||||||
"""Ein vollstaendiger Ueberwachungsdurchlauf.
|
"""Ein vollstaendiger Ueberwachungsdurchlauf.
|
||||||
|
|
||||||
Die Pruefungen laufen parallel (I/O-gebunden), die Datenbankschreibvorgaenge
|
Die Pruefungen laufen parallel (I/O-gebunden), die Datenbankschreibvorgaenge
|
||||||
danach in **einer** Transaktion -- so gibt es keinen Schreib-Wettlauf und
|
danach in **einer** Transaktion -- so gibt es keinen Schreib-Wettlauf und
|
||||||
keine halb geschriebenen Durchlaeufe.
|
keine halb geschriebenen Durchlaeufe.
|
||||||
|
|
||||||
|
``vnc_port`` (0 = aus) haengt eine zweite, sehr kurze Frage an jedes
|
||||||
|
erreichbare Geraet: nimmt dort ein VNC-Server Verbindungen an? Sie gehoert
|
||||||
|
hierher und nicht in den Seitenaufbau -- einmal je Intervall statt einmal
|
||||||
|
je Aufruf, und nur fuer Geraete, die ohnehin gerade antworten.
|
||||||
"""
|
"""
|
||||||
|
from . import vnc as vnc_dienst # spaet: vnc benutzt tcp_reachable von hier
|
||||||
|
|
||||||
report = SweepReport()
|
report = SweepReport()
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
|
|
||||||
@@ -531,15 +547,26 @@ def run_sweep(
|
|||||||
report.duration_ms = int((time.monotonic() - started) * 1000)
|
report.duration_ms = int((time.monotonic() - started) * 1000)
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
def pruefen(device: dict[str, Any]) -> tuple[dict[str, Any], PingResult, bool | 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
|
||||||
|
return device, ergebnis, vnc_dienst.erreichbar(vnc_dienst.ziel(device), vnc_port)
|
||||||
|
|
||||||
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(lambda device: (device, ping(str(device["ip"]))), devices))
|
results = list(pool.map(pruefen, devices))
|
||||||
|
|
||||||
restart_candidates: list[dict[str, Any]] = []
|
restart_candidates: list[dict[str, Any]] = []
|
||||||
with database.session() as conn:
|
with database.session() as conn:
|
||||||
with database.transaction(conn):
|
with database.transaction(conn):
|
||||||
for device, result in results:
|
for device, result, vnc_offen in results:
|
||||||
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:
|
||||||
|
merke_vnc(conn, int(device["id"]), vnc_offen)
|
||||||
if result.ok:
|
if result.ok:
|
||||||
report.online += 1
|
report.online += 1
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Bildschirmzugriff per VNC: Ziel bestimmen, Erreichbarkeit pruefen, Datei bauen.
|
||||||
|
|
||||||
|
Auf den HAPs (Raspberry Pis) laeuft ein VNC-Server. TESM kennt von jedem
|
||||||
|
Client Namen und Adresse -- daraus laesst sich die Verbindung erzeugen, statt
|
||||||
|
sie in jedem Viewer von Hand zu pflegen.
|
||||||
|
|
||||||
|
Zwei Wege, beide aus denselben zwei Angaben:
|
||||||
|
|
||||||
|
* eine **Verbindungsdatei** (``.vnc``), wie der RealVNC Viewer sie schreibt.
|
||||||
|
Sie enthaelt genau drei Zeilen -- ``ConnMethod``, ``FriendlyName``, ``Host``
|
||||||
|
-- und **nichts weiter**. Jede zusaetzliche Angabe ist eine Gelegenheit,
|
||||||
|
etwas zu verlangen, das hier niemand verlangen soll: keine
|
||||||
|
Verschluesselungsvorgabe, keine Identitaetspruefung, kein Benutzername, kein
|
||||||
|
Passwortfeld. Was am Ende gefragt wird, entscheidet ohnehin der VNC-Server
|
||||||
|
auf dem Geraet; die Datei fragt von sich aus nichts.
|
||||||
|
* eine **Adresse** (``vnc://host``) fuer den Fall, dass der Viewer als
|
||||||
|
Handler registriert ist -- ein Klick statt Herunterladen und Oeffnen.
|
||||||
|
|
||||||
|
Der Zugang erscheint nur dort, wo wirklich ein VNC-Server antwortet
|
||||||
|
(:func:`erreichbar`). Das ist der Unterschied zu einem Schalter am Geraet:
|
||||||
|
niemand muss ihn pflegen, und er zeigt nie ins Leere.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from .monitor import tcp_reachable
|
||||||
|
|
||||||
|
#: Vorgabeport von VNC (Anzeige :0). Ueberschreibbar in den Einstellungen.
|
||||||
|
STANDARD_PORT = 5900
|
||||||
|
|
||||||
|
#: So kurz, dass die Detailseite es nicht merkt, und so lang, dass ein Geraet
|
||||||
|
#: im selben Netz antworten kann. Gefragt wird ohnehin nur, wenn der Client
|
||||||
|
#: gerade als erreichbar gilt.
|
||||||
|
ZEITLIMIT = 0.6
|
||||||
|
|
||||||
|
#: Ein Name, den ein Rechner aufloesen kann: Buchstaben, Ziffern, Bindestrich,
|
||||||
|
#: Punkt als Trenner. "HAP 02" oder "Kamera Eingang" ist keiner -- dann bleibt
|
||||||
|
#: die IP-Adresse, die im Datensatz ohnehin steht.
|
||||||
|
HOSTNAME_RE = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$")
|
||||||
|
|
||||||
|
#: Zeilenende der Datei: CRLF. Der Viewer liest beides, aber die Datei landet
|
||||||
|
#: auf einem Windows-Arbeitsplatz -- und dort soll sie in jedem Editor
|
||||||
|
#: vernuenftig aussehen, wenn jemand nachsieht, was drinsteht.
|
||||||
|
ZEILE = "\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Zugang:
|
||||||
|
"""Was die Detailseite ueber den Bildschirmzugriff eines Clients weiss."""
|
||||||
|
|
||||||
|
ziel: str
|
||||||
|
port: int
|
||||||
|
erreichbar: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def adresse(self) -> str:
|
||||||
|
"""``vnc://host`` -- den Standardport schreibt niemand dazu."""
|
||||||
|
if self.port == STANDARD_PORT:
|
||||||
|
return f"vnc://{self.ziel}"
|
||||||
|
return f"vnc://{self.ziel}:{self.port}"
|
||||||
|
|
||||||
|
|
||||||
|
#: Aufloesungen, die wir schon einmal versucht haben: ``name -> (Zeit, Ergebnis)``.
|
||||||
|
#:
|
||||||
|
#: Nicht wegen der Geschwindigkeit -- ein arbeitender Resolver antwortet in
|
||||||
|
#: Bruchteilen einer Millisekunde --, sondern wegen des Gegenteils: faellt der
|
||||||
|
#: Nameserver aus, laeuft jede Abfrage in sein eigenes Zeitlimit. Ohne diesen
|
||||||
|
#: Puffer haette das der Seitenaufbau ausgebadet, und zwar bei jedem Aufruf.
|
||||||
|
_AUFLOESUNG: dict[str, tuple[float, bool]] = {}
|
||||||
|
|
||||||
|
#: Fuenf Minuten. Lang genug, damit ein kaputter Resolver nicht jede Seite
|
||||||
|
#: aufhaelt; kurz genug, dass ein neuer DNS-Eintrag nicht bis zum Neustart
|
||||||
|
#: wartet.
|
||||||
|
AUFLOESUNG_GILT = 300.0
|
||||||
|
|
||||||
|
|
||||||
|
def aufloesbar(name: str) -> bool:
|
||||||
|
"""Kennt der Resolver diesen Namen?"""
|
||||||
|
jetzt = time.monotonic()
|
||||||
|
gemerkt = _AUFLOESUNG.get(name)
|
||||||
|
if gemerkt is not None and jetzt - gemerkt[0] < AUFLOESUNG_GILT:
|
||||||
|
return gemerkt[1]
|
||||||
|
try:
|
||||||
|
socket.getaddrinfo(name, None)
|
||||||
|
ergebnis = True
|
||||||
|
except OSError:
|
||||||
|
ergebnis = False
|
||||||
|
_AUFLOESUNG[name] = (jetzt, ergebnis)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def ziel(device: Mapping[str, Any]) -> str:
|
||||||
|
"""Der Name des Clients, sonst seine IP-Adresse.
|
||||||
|
|
||||||
|
Der Name ist die bessere Wahl, wo er gilt: er ueberlebt einen
|
||||||
|
Adresswechsel, und genau so stehen die gewachsenen Verbindungen im Viewer
|
||||||
|
(``Host=hap02``). Zwei Dinge muessen dafuer stimmen -- er muss als Hostname
|
||||||
|
**taugen** ("Kamera Eingang" tut es nicht) und er muss sich **aufloesen**
|
||||||
|
lassen. Sonst zeigte die Datei auf einen Namen, den der Arbeitsplatz nicht
|
||||||
|
kennt, und niemand saehe, woran es liegt; dann ist die Adresse richtig, die
|
||||||
|
ohnehin im Datensatz steht.
|
||||||
|
|
||||||
|
Loest der Name auf eine **andere** Adresse auf als die gespeicherte, gewinnt
|
||||||
|
trotzdem der Name: dann ist der Eintrag im Bestand veraltet, nicht das DNS.
|
||||||
|
"""
|
||||||
|
name = str(device.get("name") or "").strip()
|
||||||
|
if name and HOSTNAME_RE.match(name) and aufloesbar(name):
|
||||||
|
return name.lower()
|
||||||
|
return str(device.get("ip") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def erreichbar(host: str, port: int = STANDARD_PORT) -> bool:
|
||||||
|
"""Nimmt auf diesem Host ein VNC-Server Verbindungen an?"""
|
||||||
|
if not host:
|
||||||
|
return False
|
||||||
|
return tcp_reachable(host, port, timeout=ZEITLIMIT)
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
host = ziel(device)
|
||||||
|
return Zugang(ziel=host, port=port, erreichbar=erreichbar(host, port) if pruefen else False)
|
||||||
|
|
||||||
|
|
||||||
|
def verbindungsdatei(device: Mapping[str, Any], *, port: int = STANDARD_PORT) -> str:
|
||||||
|
"""Der Inhalt der ``.vnc``-Datei -- drei Zeilen, keine vierte.
|
||||||
|
|
||||||
|
``ConnMethod=tcp`` ist die direkte Verbindung (nicht ueber den Cloud-Dienst
|
||||||
|
von RealVNC), ``FriendlyName`` der Name, den der Viewer anzeigt, ``Host``
|
||||||
|
das Ziel. Ein abweichender Port gehoert in dieser Datei an den Host, mit
|
||||||
|
Doppelpunkt -- einen eigenen Schluessel dafuer gibt es nicht.
|
||||||
|
"""
|
||||||
|
host = ziel(device)
|
||||||
|
if port != STANDARD_PORT:
|
||||||
|
host = f"{host}:{port}"
|
||||||
|
zeilen = [
|
||||||
|
"ConnMethod=tcp",
|
||||||
|
f"FriendlyName={str(device.get('name') or host).strip()}",
|
||||||
|
f"Host={host}",
|
||||||
|
]
|
||||||
|
return ZEILE.join(zeilen) + ZEILE
|
||||||
|
|
||||||
|
|
||||||
|
def dateiname(device: Mapping[str, Any]) -> str:
|
||||||
|
"""``HAP02.vnc`` -- aus dem Namen, auf Unbedenkliches beschraenkt.
|
||||||
|
|
||||||
|
Auch der Punkt faellt weg, nicht nur der Schraegstrich: ein Geraetename ist
|
||||||
|
Freitext, und ein Dateiname, in dem ".." vorkommen kann, ist eine Frage,
|
||||||
|
die man sich gar nicht erst stellen muss.
|
||||||
|
"""
|
||||||
|
name = re.sub(r"[^A-Za-z0-9_-]+", "-", str(device.get("name") or "client")).strip("-")
|
||||||
|
return f"{name or 'client'}.vnc"
|
||||||
|
|
||||||
|
|
||||||
|
def port_aus_einstellungen(extension: Any) -> int:
|
||||||
|
"""Der eingestellte Port -- oder 0, wenn der Zugang abgeschaltet ist.
|
||||||
|
|
||||||
|
Nimmt die Erweiterung statt der Verbindung, weil der Ueberwachungsprozess
|
||||||
|
ausserhalb eines Anfragekontextes laeuft und seine eigene Sitzung oeffnet.
|
||||||
|
"""
|
||||||
|
with extension.database.session() as conn:
|
||||||
|
if not extension.settings.get(conn, "vnc_enabled"):
|
||||||
|
return 0
|
||||||
|
return int(extension.settings.get(conn, "vnc_port") or STANDARD_PORT)
|
||||||
@@ -45,6 +45,27 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.",
|
"IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.",
|
||||||
section="monitoring",
|
section="monitoring",
|
||||||
),
|
),
|
||||||
|
# -- Bildschirmzugriff (VNC) -------------------------------------------- #
|
||||||
|
SettingSpec(
|
||||||
|
"vnc_enabled",
|
||||||
|
True,
|
||||||
|
"bool",
|
||||||
|
"Bildschirmzugriff (VNC) anbieten",
|
||||||
|
hint="Der Überwachungsdurchlauf fragt bei jedem erreichbaren Client kurz, ob "
|
||||||
|
"auf diesem Port ein VNC-Server antwortet; nur dort erscheint der Knopf. "
|
||||||
|
"Sehen darf ihn trotzdem nur, wer das Recht dafür hat.",
|
||||||
|
section="monitoring",
|
||||||
|
),
|
||||||
|
SettingSpec(
|
||||||
|
"vnc_port",
|
||||||
|
5900,
|
||||||
|
"int",
|
||||||
|
"VNC-Port",
|
||||||
|
hint="5900 ist Anzeige :0 – die Vorgabe eines Raspberry Pi.",
|
||||||
|
section="monitoring",
|
||||||
|
minimum=1,
|
||||||
|
maximum=65535,
|
||||||
|
),
|
||||||
# -- DHCP --------------------------------------------------------------- #
|
# -- DHCP --------------------------------------------------------------- #
|
||||||
SettingSpec("dhcp_domain", "local", "str", "Domainname", section="dhcp"),
|
SettingSpec("dhcp_domain", "local", "str", "Domainname", section="dhcp"),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
|
|||||||
@@ -99,6 +99,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if tile.vnc %}
|
||||||
|
<a class="btn btn--sm" title="Verbindungsdatei für den Bildschirm"
|
||||||
|
href="{{ url_for('devices.vnc_connection', device_id=tile.id) }}">
|
||||||
|
{{ icon('monitor', size=14) }} VNC
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
{% if current_user.has('devices.write') and tile.state == 'disabled' %}
|
{% if current_user.has('devices.write') and tile.state == 'disabled' %}
|
||||||
<form method="post" action="{{ url_for('devices.toggle_active', device_id=tile.id) }}"
|
<form method="post" action="{{ url_for('devices.toggle_active', device_id=tile.id) }}"
|
||||||
data-no-guard>
|
data-no-guard>
|
||||||
|
|||||||
@@ -16,6 +16,12 @@
|
|||||||
{{ icon('terminal', size=15) }} Konsole
|
{{ icon('terminal', size=15) }} Konsole
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if vnc and vnc.erreichbar %}
|
||||||
|
<a class="btn btn--sm" href="{{ url_for('devices.vnc_connection', device_id=device.id) }}"
|
||||||
|
title="Verbindungsdatei für {{ vnc.ziel }}:{{ vnc.port }} – öffnet den VNC-Viewer">
|
||||||
|
{{ icon('monitor', size=15) }} VNC
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
{% if restart_methods %}
|
{% if restart_methods %}
|
||||||
{% for method in restart_methods %}
|
{% for method in restart_methods %}
|
||||||
<form method="post" style="display:inline"
|
<form method="post" style="display:inline"
|
||||||
@@ -225,6 +231,24 @@
|
|||||||
<dt>Kategorie</dt><dd>{{ device.category or '–' | safe }}</dd>
|
<dt>Kategorie</dt><dd>{{ device.category or '–' | safe }}</dd>
|
||||||
<dt>Standort</dt><dd>{{ device.location or '–' | safe }}</dd>
|
<dt>Standort</dt><dd>{{ device.location or '–' | safe }}</dd>
|
||||||
<dt>Zugangsdaten</dt><dd>{{ device.credential_name or 'keine' }}</dd>
|
<dt>Zugangsdaten</dt><dd>{{ device.credential_name or 'keine' }}</dd>
|
||||||
|
{% if vnc %}
|
||||||
|
<dt>Bildschirm (VNC)</dt>
|
||||||
|
<dd>
|
||||||
|
{% if vnc.erreichbar %}
|
||||||
|
<a class="mono" href="{{ vnc.adresse }}">{{ vnc.adresse }}</a>
|
||||||
|
<div class="fs-xs faint">
|
||||||
|
<a href="{{ url_for('devices.vnc_connection', device_id=device.id) }}">
|
||||||
|
Verbindungsdatei herunterladen</a> – enthält nur Ziel und Namen.
|
||||||
|
Der Link daneben braucht einen Viewer, der <code>vnc://</code> übernimmt.
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="faint">
|
||||||
|
kein VNC-Server auf {{ vnc.ziel }}:{{ vnc.port }}
|
||||||
|
{%- if device.state != 'online' %} (Client antwortet gerade nicht){% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
{% endif %}
|
||||||
<dt>Automatischer Neustart</dt><dd>{{ device.auto_restart | yes_no }}</dd>
|
<dt>Automatischer Neustart</dt><dd>{{ device.auto_restart | yes_no }}</dd>
|
||||||
<dt>Angelegt</dt><dd>{{ device.created_at | dt }}</dd>
|
<dt>Angelegt</dt><dd>{{ device.created_at | dt }}</dd>
|
||||||
<dt>Geändert</dt>
|
<dt>Geändert</dt>
|
||||||
|
|||||||
@@ -60,9 +60,18 @@
|
|||||||
<dd>
|
<dd>
|
||||||
{% if state.modules %}
|
{% if state.modules %}
|
||||||
{% for module in modules %}
|
{% for module in modules %}
|
||||||
{% if module in state.modules %}{{ pill(module_labels[module], 'online') }}
|
{% if module in state.modules and module in state.modules_off %}
|
||||||
|
{{ pill(module_labels[module] ~ ' (abgeschaltet)', 'warning') }}
|
||||||
|
{% elif module in state.modules %}{{ pill(module_labels[module], 'online') }}
|
||||||
{% else %}{{ pill(module_labels[module], '') }}{% endif %}
|
{% else %}{{ pill(module_labels[module], '') }}{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% if state.modules_off %}
|
||||||
|
<div class="fs-xs faint">
|
||||||
|
Abgeschaltet wurde in
|
||||||
|
<a href="{{ url_for('settings.system') }}">Verwaltung → System</a>;
|
||||||
|
die Lizenz ist davon unberührt.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% else %}<span class="faint">keine Zusatzmodule</span>{% endif %}
|
{% else %}<span class="faint">keine Zusatzmodule</span>{% endif %}
|
||||||
</dd>
|
</dd>
|
||||||
<dt>Lizenzserver</dt>
|
<dt>Lizenzserver</dt>
|
||||||
|
|||||||
@@ -46,11 +46,46 @@
|
|||||||
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
|
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
|
||||||
checked=values.dashboard_public,
|
checked=values.dashboard_public,
|
||||||
hint_text=specs['dashboard_public'].hint) }}
|
hint_text=specs['dashboard_public'].hint) }}
|
||||||
|
{{ checkbox('vnc_enabled', 'Bildschirmzugriff (VNC) anbieten',
|
||||||
|
checked=values.vnc_enabled,
|
||||||
|
hint_text=specs['vnc_enabled'].hint) }}
|
||||||
|
<div class="form-grid">
|
||||||
|
{{ field('vnc_port', 'VNC-Port', value=values.vnc_port, type='number',
|
||||||
|
hint_text=specs['vnc_port'].hint) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% if module_rows %}
|
||||||
|
<section class="card">
|
||||||
|
<div class="card__header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card__title">{{ icon('puzzle', size=17) }} Module</div>
|
||||||
|
<div class="card__sub">
|
||||||
|
Die Lizenz sagt, was erlaubt ist – hier steht, was dieses Haus benutzt.
|
||||||
|
Ein abgeschaltetes Modul verschwindet aus der Navigation, und seine Seiten
|
||||||
|
antworten wie ohne Lizenz. Bestehende Daten bleiben unverändert stehen und
|
||||||
|
sind nach dem Wiedereinschalten wieder da.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('settings.modules_save') }}">
|
||||||
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
<div class="card__body stack">
|
||||||
|
{% for modul in module_rows %}
|
||||||
|
{{ checkbox('module_' ~ modul.key, modul.label, checked=modul.enabled,
|
||||||
|
hint_text=('In der Lizenz enthalten.' if modul.licensed
|
||||||
|
else 'Nicht lizenziert – bleibt aus, bis die Lizenz es
|
||||||
|
freischaltet. Der Haken gilt dann sofort.')) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -189,7 +224,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
{{ nav_order_card(nav_items, nav_order, nav_child_order,
|
{{ nav_order_card(nav_baum, nav_order, nav_child_order,
|
||||||
url_for('admin.nav_order'), csrf_field_name, csrf_token()) }}
|
url_for('admin.nav_order'), csrf_field_name, csrf_token()) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from types import FrameType
|
|||||||
from tesm_core.extension import core
|
from tesm_core.extension import core
|
||||||
|
|
||||||
from . import create_app
|
from . import create_app
|
||||||
from .services import inventory, monitor
|
from .services import inventory, monitor, vnc
|
||||||
|
|
||||||
logger = logging.getLogger("tesm.monitor")
|
logger = logging.getLogger("tesm.monitor")
|
||||||
|
|
||||||
@@ -82,6 +82,7 @@ class MonitorDaemon:
|
|||||||
keystore=self.extension.keystore,
|
keystore=self.extension.keystore,
|
||||||
license_allows_auto_restart=self._auto_restart_allowed(),
|
license_allows_auto_restart=self._auto_restart_allowed(),
|
||||||
failures_before_restart=self._failures_before_restart(),
|
failures_before_restart=self._failures_before_restart(),
|
||||||
|
vnc_port=vnc.port_aus_einstellungen(self.extension),
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Durchlauf: %s geprüft, %s online, %s offline, %s Neustarts (%s erfolgreich), "
|
"Durchlauf: %s geprüft, %s online, %s offline, %s Neustarts (%s erfolgreich), "
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.8.2"
|
version = "2.8.3"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -102,6 +102,14 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
),
|
),
|
||||||
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
|
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
|
||||||
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
|
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
|
||||||
|
SettingSpec(
|
||||||
|
"modules_disabled",
|
||||||
|
[],
|
||||||
|
"json",
|
||||||
|
"Vor Ort abgeschaltete Module",
|
||||||
|
hint="Die Lizenz sagt, was erlaubt ist; das hier sagt, was benutzt wird.",
|
||||||
|
section="system",
|
||||||
|
),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"license_server_ca",
|
"license_server_ca",
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import urllib.request
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable, Iterable
|
||||||
|
|
||||||
import tesm_licensing as lic
|
import tesm_licensing as lic
|
||||||
|
|
||||||
@@ -54,6 +54,21 @@ SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
|
|||||||
EXPIRY_HINT_DAYS = 90
|
EXPIRY_HINT_DAYS = 90
|
||||||
SETTING_EXPORT_GRACE = "license_export_grace"
|
SETTING_EXPORT_GRACE = "license_export_grace"
|
||||||
|
|
||||||
|
#: Module, die der Betreiber vor Ort abgeschaltet hat (Liste von Namen).
|
||||||
|
#:
|
||||||
|
#: Die Lizenz sagt, *was erlaubt ist*; sie sagt nicht, was ein Haus auch
|
||||||
|
#: benutzen will. Wer kein DHCP ueber TESM fahren will, weil ein anderer Server
|
||||||
|
#: das tut, soll den Punkt nicht wegdruecken muessen -- und vor allem soll
|
||||||
|
#: niemand versehentlich eine fremde Kea-Konfiguration ueberschreiben koennen.
|
||||||
|
#: Abschalten ist deshalb mehr als Aufraeumen der Navigation: die Sperre sitzt
|
||||||
|
#: an derselben Stelle wie die Lizenzpruefung, also vor jeder Route.
|
||||||
|
#: Bewusst **ohne** das Praefix ``license_``: Sicherungen lassen alles mit
|
||||||
|
#: diesem Praefix draussen, weil Lizenz- und Aktivierungszustand zur Maschine
|
||||||
|
#: gehoeren und nicht in ein Backup. Der Schalter ist aber keine Lizenzangabe,
|
||||||
|
#: sondern eine Entscheidung des Hauses -- die soll eine Wiederherstellung
|
||||||
|
#: mitbringen.
|
||||||
|
SETTING_MODULES_OFF = "modules_disabled"
|
||||||
|
|
||||||
STATUS_INACTIVE = "inactive"
|
STATUS_INACTIVE = "inactive"
|
||||||
STATUS_PENDING = "pending"
|
STATUS_PENDING = "pending"
|
||||||
STATUS_ACTIVE = "active"
|
STATUS_ACTIVE = "active"
|
||||||
@@ -121,6 +136,8 @@ class LicenseState:
|
|||||||
last_heartbeat_at: str = ""
|
last_heartbeat_at: str = ""
|
||||||
heartbeat_error: str = ""
|
heartbeat_error: str = ""
|
||||||
pending_action: str = ""
|
pending_action: str = ""
|
||||||
|
#: Vor Ort abgeschaltet -- unabhaengig davon, was die Lizenz erlaubt.
|
||||||
|
modules_off: frozenset[str] = frozenset()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def role(self) -> str:
|
def role(self) -> str:
|
||||||
@@ -128,8 +145,14 @@ class LicenseState:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def modules(self) -> frozenset[str]:
|
def modules(self) -> frozenset[str]:
|
||||||
|
"""Was die Lizenz freischaltet -- ohne Ruecksicht auf den Schalter."""
|
||||||
return self.status.modules if self.status else frozenset()
|
return self.status.modules if self.status else frozenset()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def modules_in_use(self) -> frozenset[str]:
|
||||||
|
"""Was tatsaechlich laeuft: lizenziert **und** nicht abgeschaltet."""
|
||||||
|
return self.modules - self.modules_off
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def customer_name(self) -> str:
|
def customer_name(self) -> str:
|
||||||
return str((self.status.customer if self.status else {}).get("name") or "")
|
return str((self.status.customer if self.status else {}).get("name") or "")
|
||||||
@@ -179,6 +202,7 @@ class LicenseManager:
|
|||||||
"""Liest Lizenzdatei und Status neu ein."""
|
"""Liest Lizenzdatei und Status neu ein."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
state = LicenseState()
|
state = LicenseState()
|
||||||
|
state.modules_off = self.abgeschaltete_module(conn)
|
||||||
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
|
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
|
||||||
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
|
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
|
||||||
pending = self._pending(conn)
|
pending = self._pending(conn)
|
||||||
@@ -239,9 +263,51 @@ class LicenseManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def module_active(self, name: str) -> bool:
|
def module_active(self, name: str) -> bool:
|
||||||
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) freigeschaltet?"""
|
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) in Betrieb?
|
||||||
|
|
||||||
|
Zwei Bedingungen, und beide muessen gelten: die Lizenz muss es
|
||||||
|
freischalten, und der Betreiber darf es nicht abgeschaltet haben. Diese
|
||||||
|
eine Funktion fragen die Navigation, jede Route und die Hintergrund-
|
||||||
|
aufgaben -- darum sitzt der Schalter hier und nicht in einer Vorlage.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
self.active()
|
||||||
|
and name in self._state.modules
|
||||||
|
and name not in self._state.modules_off
|
||||||
|
)
|
||||||
|
|
||||||
|
def module_licensed(self, name: str) -> bool:
|
||||||
|
"""Schaltet die Lizenz das Modul frei? (Ohne den Schalter vor Ort.)"""
|
||||||
return self.active() and name in self._state.modules
|
return self.active() and name in self._state.modules
|
||||||
|
|
||||||
|
def abgeschaltete_module(self, conn: sqlite3.Connection) -> frozenset[str]:
|
||||||
|
"""Liest die Liste aus den Einstellungen."""
|
||||||
|
gespeichert = self.settings.get(conn, SETTING_MODULES_OFF) or []
|
||||||
|
if isinstance(gespeichert, str): # Altbestand: ein einzelner Name
|
||||||
|
gespeichert = [gespeichert]
|
||||||
|
return frozenset(str(name) for name in gespeichert)
|
||||||
|
|
||||||
|
def module_abschalten(
|
||||||
|
self, conn: sqlite3.Connection, namen: Iterable[str], *, actor: str = ""
|
||||||
|
) -> tuple[frozenset[str], frozenset[str]]:
|
||||||
|
"""Setzt die Liste der abgeschalteten Module neu.
|
||||||
|
|
||||||
|
Gibt ``(eingeschaltet, abgeschaltet)`` zurueck -- also nur das, was sich
|
||||||
|
wirklich geaendert hat; der Aufrufer schreibt daraus seinen
|
||||||
|
Protokolleintrag.
|
||||||
|
|
||||||
|
Der Zustand wird hier nicht neu eingelesen, sondern nur an dieser einen
|
||||||
|
Stelle nachgezogen: ein voller Neuaufbau wuerde die Lizenzdatei erneut
|
||||||
|
pruefen, obwohl sich an ihr nichts geaendert hat -- und der Schalter ist
|
||||||
|
das Einzige, was jetzt anders ist.
|
||||||
|
"""
|
||||||
|
vorher = self.abgeschaltete_module(conn)
|
||||||
|
nachher = frozenset(str(name) for name in namen)
|
||||||
|
self.settings.set(conn, SETTING_MODULES_OFF, sorted(nachher), actor=actor)
|
||||||
|
with self._lock:
|
||||||
|
self._state.modules_off = nachher
|
||||||
|
return vorher - nachher, nachher - vorher
|
||||||
|
|
||||||
def feature_allowed(self, feature: str) -> bool:
|
def feature_allowed(self, feature: str) -> bool:
|
||||||
"""Lizenzpflichtige Einzelfunktion.
|
"""Lizenzpflichtige Einzelfunktion.
|
||||||
|
|
||||||
|
|||||||
@@ -328,6 +328,83 @@ def ensure_presets(
|
|||||||
return created
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def unberuehrte_vorlagen_entfernen(
|
||||||
|
conn: sqlite3.Connection, tree: PermissionTree, presets: Sequence[RolePreset]
|
||||||
|
) -> list[str]:
|
||||||
|
"""Legt Vorlagengruppen in den Papierkorb -- aber nur unberuehrte.
|
||||||
|
|
||||||
|
Wird gebraucht, wenn eine Vorlage aus der Auslieferung verschwindet. Auf
|
||||||
|
einer frischen Installation entsteht sie dann gar nicht erst; auf einer
|
||||||
|
bestehenden steht sie in der Datenbank, und eine Migration, die sie einfach
|
||||||
|
loescht, nimmt einem Kunden womoeglich eine Gruppe weg, mit der er
|
||||||
|
arbeitet.
|
||||||
|
|
||||||
|
Darum diese fuenf Bedingungen, und alle fuenf muessen zutreffen:
|
||||||
|
|
||||||
|
* Die Gruppe stammt **aus der Auslieferung**: ihr ``preset_key`` ist der der
|
||||||
|
Vorlage. Diese Kennung setzt nur :func:`ensure_presets`; eine selbst
|
||||||
|
angelegte Gruppe hat sie nicht, auch wenn sie zufaellig denselben Namen
|
||||||
|
traegt. Ueber den Namen wird bewusst *nicht* gesucht -- sonst raeumte
|
||||||
|
dieser Schritt jemandem seine eigene Gruppe "Betrieb" weg.
|
||||||
|
* Sie heisst noch so, wie sie ausgeliefert wurde. Ein anderer Name heisst:
|
||||||
|
jemand hat sie uebernommen.
|
||||||
|
* Die Gruppe hat **keine Mitglieder**. Wer Mitglieder hat, wird benutzt.
|
||||||
|
* Ihre Rechte sind **unveraendert** die der Vorlage. Jede Abweichung heisst:
|
||||||
|
jemand hat sie angepasst, also ist sie seine, nicht unsere.
|
||||||
|
* Sie ist keine Systemgruppe.
|
||||||
|
|
||||||
|
Geloescht wird weich: die Gruppe liegt im Papierkorb und kommt mit einem
|
||||||
|
Klick zurueck. Gibt die Namen der einsortierten Gruppen zurueck.
|
||||||
|
"""
|
||||||
|
entfernt: list[str] = []
|
||||||
|
gruppen = list_groups(conn)
|
||||||
|
for preset in presets:
|
||||||
|
gruppe = next(
|
||||||
|
(
|
||||||
|
g
|
||||||
|
for g in gruppen
|
||||||
|
if g.preset_key
|
||||||
|
and g.preset_key == preset.key
|
||||||
|
and g.name.lower() == preset.name.lower()
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if gruppe is None or gruppe.is_system or gruppe.member_count:
|
||||||
|
continue
|
||||||
|
if set(gruppe.permissions) != set(tree.expand_preset(preset)):
|
||||||
|
continue
|
||||||
|
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), gruppe.id))
|
||||||
|
entfernt.append(gruppe.name)
|
||||||
|
return entfernt
|
||||||
|
|
||||||
|
|
||||||
|
def funktionsgruppe_anlegen(
|
||||||
|
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
|
||||||
|
) -> int:
|
||||||
|
"""Legt die Vollzugriffsgruppe an, falls sie fehlt, und gibt ihre Nummer.
|
||||||
|
|
||||||
|
Getrennt von :func:`funktionsgruppe_fuer_administratoren`, weil das zwei
|
||||||
|
verschiedene Dinge sind: hier entsteht nur die Gruppe, dort bekommt sie
|
||||||
|
**jeder** Administrator. Wer ein einzelnes Konto aufnehmen will -- etwa
|
||||||
|
das, das an der Kommandozeile entsteht --, braucht das erste ohne das
|
||||||
|
zweite; sonst faengt sich ein Administrator, der die Gruppe bewusst
|
||||||
|
abgelegt hat, sie beim naechsten Aufruf wieder ein.
|
||||||
|
"""
|
||||||
|
zeile = Database.one(
|
||||||
|
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,)
|
||||||
|
)
|
||||||
|
if zeile is not None:
|
||||||
|
return int(zeile["id"])
|
||||||
|
cursor = conn.execute(
|
||||||
|
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
|
||||||
|
"created_at, updated_at) VALUES (?,?,0,0,?,?,?)",
|
||||||
|
(preset.name, preset.description, preset.key, _now(), _now()),
|
||||||
|
)
|
||||||
|
gruppe = int(cursor.lastrowid or 0)
|
||||||
|
_write_permissions(conn, gruppe, tree.expand_preset(preset))
|
||||||
|
return gruppe
|
||||||
|
|
||||||
|
|
||||||
def funktionsgruppe_fuer_administratoren(
|
def funktionsgruppe_fuer_administratoren(
|
||||||
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
|
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -345,20 +422,7 @@ def funktionsgruppe_fuer_administratoren(
|
|||||||
|
|
||||||
Gibt die Nummer der Gruppe zurueck.
|
Gibt die Nummer der Gruppe zurueck.
|
||||||
"""
|
"""
|
||||||
zeile = Database.one(
|
gruppe = funktionsgruppe_anlegen(conn, tree, preset)
|
||||||
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,)
|
|
||||||
)
|
|
||||||
if zeile is None:
|
|
||||||
cursor = conn.execute(
|
|
||||||
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
|
|
||||||
"created_at, updated_at) VALUES (?,?,0,0,?,?,?)",
|
|
||||||
(preset.name, preset.description, preset.key, _now(), _now()),
|
|
||||||
)
|
|
||||||
gruppe = int(cursor.lastrowid or 0)
|
|
||||||
_write_permissions(conn, gruppe, tree.expand_preset(preset))
|
|
||||||
else:
|
|
||||||
gruppe = int(zeile["id"])
|
|
||||||
|
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
|
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in new issue
Block a user