Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85c40ccfbd | ||
|
|
0ac651a1b9 | ||
|
|
4275c84a6c | ||
|
|
4f4310bfe4 | ||
|
|
1ad2f3f9a5 | ||
|
|
9d5eac1db2 | ||
|
|
2fd78ac26b | ||
|
|
86c09d8a0f | ||
|
|
70f6b005a9 | ||
|
|
57eb7764cc | ||
|
|
30c2c56656 |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm"
|
||||
version = "2.8.1"
|
||||
version = "2.8.12"
|
||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -39,11 +39,21 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
||||
|
||||
from ..services import inventory, restart
|
||||
|
||||
from ..services import vnc as vnc_dienst
|
||||
|
||||
conn = db()
|
||||
counts = monitor.dashboard_counts(conn)
|
||||
rows = inventory.list_devices(conn, only_active=not authenticated)
|
||||
|
||||
detailed = authenticated and current_user.has("devices.read")
|
||||
# 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")
|
||||
vnc_port = (
|
||||
int(core().settings.get(conn, "vnc_port") or vnc_dienst.STANDARD_PORT)
|
||||
if vnc_erlaubt and core().settings.get(conn, "vnc_enabled")
|
||||
else 0
|
||||
)
|
||||
tiles: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
entry: dict[str, Any] = {
|
||||
@@ -74,6 +84,23 @@ 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.
|
||||
#
|
||||
# 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
|
||||
# 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 ""
|
||||
),
|
||||
}
|
||||
)
|
||||
tiles.append(entry)
|
||||
|
||||
@@ -11,14 +11,23 @@ from __future__ import annotations
|
||||
|
||||
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.auth.models import current_user, permission_required, reauth_required
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.db import Database
|
||||
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")
|
||||
|
||||
@@ -360,6 +369,69 @@ def detail(device_id: int):
|
||||
host_keys=host_keys,
|
||||
method_labels=monitor.METHOD_LABELS,
|
||||
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, zugang.ziel)
|
||||
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.licensing_client import LicenseError
|
||||
|
||||
import tesm_licensing as lic
|
||||
|
||||
bp = Blueprint("settings", __name__, url_prefix="/administration")
|
||||
|
||||
|
||||
@@ -46,6 +48,28 @@ def _license_hint(feature: str) -> str:
|
||||
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:
|
||||
sysops = core().sysops
|
||||
if sysops is None:
|
||||
@@ -79,6 +103,8 @@ def system():
|
||||
"monitor_failures_before_restart",
|
||||
"monitor_auto_restart",
|
||||
"dashboard_public",
|
||||
"vnc_enabled",
|
||||
"vnc_port",
|
||||
"terminal_switches_enabled",
|
||||
"terminal_clients_enabled",
|
||||
"terminal_idle_timeout",
|
||||
@@ -94,11 +120,16 @@ def system():
|
||||
hostname=(sysops.read_command(["hostname"]).stdout.strip() if sysops else ""),
|
||||
timezone=_current_timezone(),
|
||||
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_child_order=extension.settings.get(conn, "nav_child_order") or {},
|
||||
license_hint=_license_hint("monitor_interval"),
|
||||
sysops_available=bool(sysops and sysops.available),
|
||||
module_rows=_module_rows(conn),
|
||||
)
|
||||
|
||||
|
||||
@@ -114,6 +145,8 @@ def system_save():
|
||||
"monitor_failures_before_restart",
|
||||
"monitor_auto_restart",
|
||||
"dashboard_public",
|
||||
"vnc_enabled",
|
||||
"vnc_port",
|
||||
"terminal_switches_enabled",
|
||||
"terminal_clients_enabled",
|
||||
"terminal_idle_timeout",
|
||||
@@ -167,6 +200,56 @@ def system_save():
|
||||
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")
|
||||
@permission_required("settings_system.execute")
|
||||
def set_hostname():
|
||||
@@ -226,7 +309,7 @@ def run_check_now():
|
||||
extension = core()
|
||||
|
||||
def worker() -> None:
|
||||
from ..services import inventory, monitor
|
||||
from ..services import inventory, monitor, vnc
|
||||
|
||||
allowed = extension.license is None or extension.license.feature_allowed(
|
||||
"auto_poe_restart"
|
||||
@@ -236,6 +319,7 @@ def run_check_now():
|
||||
live=extension.registry["live_log"],
|
||||
keystore=extension.keystore,
|
||||
license_allows_auto_restart=allowed,
|
||||
vnc_port=vnc.port_aus_einstellungen(extension),
|
||||
)
|
||||
|
||||
threading.Thread(target=worker, name="manual-sweep", daemon=True).start()
|
||||
|
||||
@@ -211,7 +211,17 @@ def init_app(app) -> None: # type: ignore[no-untyped-def]
|
||||
|
||||
|
||||
def _send(ws, kind: str, text: str) -> None: # type: ignore[no-untyped-def]
|
||||
ws.send(json.dumps({"type": kind, "data": text}))
|
||||
"""Schickt eine Nachricht. Ein geschlossener Socket ist kein Fehler.
|
||||
|
||||
Bricht der Browser die Sitzung ab, schlaegt das Senden fehl -- und die
|
||||
Ausnahme daraus verdeckte bisher den eigentlichen Grund, warum die
|
||||
Sitzung endete: im Protokoll stand ein Stapelverlauf aus ``ws.send``,
|
||||
nicht die Meldung des Switches.
|
||||
"""
|
||||
try:
|
||||
ws.send(json.dumps({"type": kind, "data": text}))
|
||||
except Exception: # noqa: BLE001 - der Browser ist weg, das ist keine Stoerung
|
||||
logger.debug("Konsolennachricht nicht mehr zustellbar (%s)", kind)
|
||||
|
||||
|
||||
def _transcript_name(label: str) -> str:
|
||||
@@ -220,8 +230,17 @@ def _transcript_name(label: str) -> str:
|
||||
|
||||
|
||||
def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-untyped-def]
|
||||
"""Eine Konsolensitzung, von der Berechtigung bis zum Abmelden.
|
||||
|
||||
Jede Stufe schickt eine Zeile in die Oberflaeche. Das ist keine Zierde:
|
||||
eine Konsole, die nichts sagt, laesst offen, ob die Berechtigung fehlt,
|
||||
die Zugangsdaten, die Verbindung oder die Antwort des Geraets -- und genau
|
||||
diese Frage stand im Betrieb im Raum. Sichtbar sind die Zeilen erst
|
||||
nuetzlich, wenn sie **vor** dem Warten kommen, nicht danach.
|
||||
"""
|
||||
extension = core()
|
||||
conn = db()
|
||||
_send(ws, "info", "Sitzung wird geöffnet …")
|
||||
|
||||
# -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein
|
||||
# WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich.
|
||||
@@ -255,6 +274,7 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
|
||||
/ f"{stamp}-{_transcript_name(current_user.username)}-{_transcript_name(target.label)}.log"
|
||||
)
|
||||
|
||||
_send(ws, "info", f"Ziel: {target.label} ({target.host}:{target.port})")
|
||||
try:
|
||||
username, password = inventory.credential_secret(
|
||||
conn, extension.keystore, target.credential_id
|
||||
@@ -262,6 +282,7 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
|
||||
except inventory.ValidationError as exc:
|
||||
_send(ws, "error", str(exc))
|
||||
return
|
||||
_send(ws, "info", f"Anmeldung als {username} …")
|
||||
|
||||
with extension.database.transaction(conn):
|
||||
audit.log(
|
||||
@@ -343,6 +364,7 @@ def _run_switch_session( # type: ignore[no-untyped-def]
|
||||
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
|
||||
) -> int:
|
||||
commands = 0
|
||||
_send(ws, "info", "Warte auf die Eingabeaufforderung des Switches …")
|
||||
with switchcli.SwitchCli(
|
||||
conn, host=target.host, port=target.port, username=username, password=password
|
||||
) as cli:
|
||||
@@ -375,6 +397,7 @@ def _run_client_session( # type: ignore[no-untyped-def]
|
||||
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
|
||||
) -> int:
|
||||
commands = 0
|
||||
_send(ws, "info", "Verbindung wird aufgebaut …")
|
||||
with shellcli.ShellSession(
|
||||
conn, host=target.host, port=target.port, username=username, password=password
|
||||
) as session:
|
||||
|
||||
+164
-2
@@ -69,13 +69,41 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
|
||||
)
|
||||
user_id = int(cursor.lastrowid or 0)
|
||||
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(
|
||||
conn,
|
||||
actor="cli",
|
||||
actor_id=user_id,
|
||||
action="user.admin_created",
|
||||
target=username,
|
||||
detail=f"über die Kommandozeile {action}",
|
||||
detail=(
|
||||
f"über die Kommandozeile {action}, Gruppe "
|
||||
f"{FUNKTIONEN_VOLLZUGRIFF.name} zugewiesen"
|
||||
),
|
||||
severity="warning",
|
||||
)
|
||||
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:
|
||||
app, extension = _app_and_core()
|
||||
from .services import inventory, monitor
|
||||
from .services import inventory, monitor, vnc
|
||||
|
||||
with app.app_context():
|
||||
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"],
|
||||
keystore=extension.keystore,
|
||||
license_allows_auto_restart=allowed,
|
||||
vnc_port=vnc.port_aus_einstellungen(extension),
|
||||
)
|
||||
print(
|
||||
f"{report.checked} geprüft, {report.online} online, {report.offline} offline, "
|
||||
@@ -216,6 +245,123 @@ def cmd_web_setup(args: argparse.Namespace) -> int:
|
||||
return run_web_setup(app, extension, args)
|
||||
|
||||
|
||||
def cmd_switch_test(args: argparse.Namespace) -> int:
|
||||
"""Prueft Stufe fuer Stufe, wo eine Switch-Sitzung haengt.
|
||||
|
||||
Warum ein eigener Befehl und nicht die Konsole im Browser: dort liegen
|
||||
zwischen dem Switch und dem Auge ein WebSocket, ein Reverse Proxy und
|
||||
JavaScript. Wenn nichts ankommt, ist die erste Frage, *welche* dieser
|
||||
Schichten schweigt. Hier gibt es keine davon -- und jede Stufe sagt, was
|
||||
sie bekommen hat, notfalls als rohe Bytes.
|
||||
|
||||
Liest nur, ausser mit ``--zyklus``: dann wird der Port wirklich
|
||||
geschaltet.
|
||||
"""
|
||||
from .services import inventory, switchcli
|
||||
from .services.ssh import SshError
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core
|
||||
|
||||
app = create_app()
|
||||
erweiterung = core(app)
|
||||
|
||||
def sagen(stufe: str, text: str = "") -> None:
|
||||
print(f" {stufe:<28} {text}".rstrip())
|
||||
|
||||
with app.app_context():
|
||||
with erweiterung.database.session() as conn:
|
||||
zeile = Database.one(
|
||||
conn,
|
||||
"SELECT id, hostname, ip, ssh_port, credential_id FROM switches "
|
||||
"WHERE (hostname=? COLLATE NOCASE OR ip=?) AND deleted_at IS NULL",
|
||||
(args.switch, args.switch),
|
||||
)
|
||||
if zeile is None:
|
||||
print(f"Kein Switch mit Name oder Adresse {args.switch!r}.")
|
||||
print("Vorhanden:")
|
||||
for andere in Database.all(
|
||||
conn, "SELECT hostname, ip FROM switches WHERE deleted_at IS NULL"
|
||||
):
|
||||
print(f" {andere['hostname']} ({andere['ip']})")
|
||||
return 2
|
||||
|
||||
host = str(zeile["ip"])
|
||||
ssh_port = int(zeile["ssh_port"] or 22)
|
||||
print(f"Switch {zeile['hostname']} ({host}:{ssh_port})")
|
||||
print()
|
||||
|
||||
if not zeile["credential_id"]:
|
||||
sagen("Zugangsdaten", "FEHLT -- dem Switch sind keine zugeordnet")
|
||||
return 1
|
||||
try:
|
||||
benutzer, passwort = inventory.credential_secret(
|
||||
conn, erweiterung.keystore, int(zeile["credential_id"])
|
||||
)
|
||||
except Exception as fehler: # noqa: BLE001
|
||||
sagen("Zugangsdaten", f"nicht lesbar: {fehler}")
|
||||
return 1
|
||||
sagen("Zugangsdaten", f"{benutzer} (Passwort {len(passwort)} Zeichen)")
|
||||
|
||||
from .services import ssh as ssh_modul
|
||||
|
||||
for record in ssh_modul.all_known_keys(conn, host, ssh_port):
|
||||
sagen("Freigegebener Schluessel", f"{record.key_type} {record.fingerprint}")
|
||||
|
||||
try:
|
||||
sitzung = switchcli.SwitchCli(
|
||||
conn,
|
||||
host=host,
|
||||
port=ssh_port,
|
||||
username=benutzer,
|
||||
password=passwort,
|
||||
)
|
||||
with sitzung as cli:
|
||||
sagen("Anmeldung", "erfolgreich")
|
||||
erstes = cli.full_transcript
|
||||
sagen("Erste Antwort", f"{len(erstes)} Zeichen")
|
||||
print()
|
||||
print("--- was der Switch nach der Anmeldung geschickt hat ---")
|
||||
print(erstes if erstes.strip() else "(nichts)")
|
||||
print("--- Ende ---")
|
||||
print()
|
||||
|
||||
if args.port:
|
||||
ausgabe = cli.send(
|
||||
f"show power-over-ethernet {args.port}", tolerate_error=True
|
||||
)
|
||||
sagen("Zustandsabfrage", f"{len(ausgabe)} Zeichen")
|
||||
print("--- show power-over-ethernet ---")
|
||||
print(ausgabe)
|
||||
print("--- Ende ---")
|
||||
sagen("Erkannter Zustand", cli.poe_state(args.port))
|
||||
|
||||
if args.zyklus:
|
||||
if not args.port:
|
||||
print("Fuer --zyklus braucht es --port.")
|
||||
return 2
|
||||
ergebnis = cli.cycle_poe(args.port)
|
||||
sagen("Portzyklus", "erfolgreich" if ergebnis.ok else ergebnis.error)
|
||||
for schritt in ergebnis.steps:
|
||||
sagen("", schritt)
|
||||
if not ergebnis.ok:
|
||||
return 1
|
||||
except SshError as fehler:
|
||||
sagen("FEHLGESCHLAGEN", str(fehler))
|
||||
# Gerade dann sind die Bytes interessant: "kein Prompt" kann
|
||||
# heissen, dass nichts kam -- oder dass etwas kam, das nicht
|
||||
# erkannt wurde. Das sieht man nur im Rohtext, und
|
||||
# Steuerzeichen sieht man nur als repr().
|
||||
roh = "".join(sitzung.transcript)
|
||||
print()
|
||||
print("--- rohe Antwort des Switches (mit Steuerzeichen) ---")
|
||||
print(repr(roh) if roh else "(nichts)")
|
||||
print("--- Ende ---")
|
||||
return 1
|
||||
print()
|
||||
print("Fertig.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_convert_legacy(args: argparse.Namespace) -> int:
|
||||
"""Uebersetzt einen Export des alten TESM in das neue Format.
|
||||
|
||||
@@ -418,6 +564,22 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
check = sub.add_parser("check-now", help="Einen Überwachungsdurchlauf sofort ausführen")
|
||||
check.set_defaults(func=cmd_check_now)
|
||||
|
||||
pruefung = sub.add_parser(
|
||||
"switch-test",
|
||||
help="Eine Switch-Sitzung Stufe fuer Stufe pruefen",
|
||||
description=(
|
||||
"Meldet sich am Switch an und zeigt, was er schickt -- ohne Browser, "
|
||||
"ohne WebSocket, ohne Reverse Proxy. Liest nur; mit --zyklus wird der "
|
||||
"Port wirklich geschaltet."
|
||||
),
|
||||
)
|
||||
pruefung.add_argument("switch", help="Name oder IP des Switches, wie in TESM hinterlegt")
|
||||
pruefung.add_argument("--port", help="Portbezeichnung, z. B. 1/8 -- fragt den Zustand ab")
|
||||
pruefung.add_argument(
|
||||
"--zyklus", action="store_true", help="PoE wirklich aus- und wieder einschalten"
|
||||
)
|
||||
pruefung.set_defaults(func=cmd_switch_test)
|
||||
|
||||
legacy = sub.add_parser(
|
||||
"convert-legacy",
|
||||
help="Export des alten TESM in das neue Format übersetzen",
|
||||
|
||||
@@ -78,6 +78,26 @@ DEVICE_RESTART_RPC = Resource(
|
||||
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
|
||||
#: wie die Paketaktualisierung. Eine Sitzung auf einem Geraet ist aber etwas
|
||||
#: anderes als ein Auftrag, den der Server abarbeitet: hier tippt ein Mensch.
|
||||
@@ -316,6 +336,7 @@ MONITORING_AREA = Area(
|
||||
DEVICE_RESTART_SSH,
|
||||
DEVICE_RESTART_RPC,
|
||||
DEVICE_CONSOLE,
|
||||
DEVICE_VNC,
|
||||
SWITCHES,
|
||||
SWITCH_CONSOLE,
|
||||
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, ...] = (
|
||||
RolePreset(
|
||||
key="viewer",
|
||||
@@ -400,78 +430,6 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
|
||||
),
|
||||
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(
|
||||
key="administration",
|
||||
name="Administration",
|
||||
|
||||
@@ -23,6 +23,8 @@ Frische Zeitlinie ab Version 1 -- kein Nachbau der 35 gewachsenen
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from tesm_core.migrations import Migration
|
||||
|
||||
V1 = """
|
||||
@@ -393,6 +395,148 @@ def uebergang_funktionsgruppe(conn) -> None: # type: ignore[no-untyped-def]
|
||||
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;
|
||||
"""
|
||||
|
||||
|
||||
# Version 11 ----------------------------------------------------------------
|
||||
#
|
||||
# Nicht nur *ob* dort ein VNC-Server antwortet, sondern *wohin* gefragt wurde.
|
||||
# Die Kacheln der Statusuebersicht verlinken die Verbindung unmittelbar
|
||||
# (vnc://hap02); das Ziel dafuer aus dem Namen neu herzuleiten hiesse, bei
|
||||
# jedem Seitenaufbau je Kachel eine Namensaufloesung zu versuchen. Der
|
||||
# Durchlauf hat sie ohnehin schon gemacht -- also schreibt er sie hin.
|
||||
V11 = """
|
||||
ALTER TABLE device_status ADD COLUMN vnc_target TEXT NOT NULL DEFAULT '';
|
||||
"""
|
||||
|
||||
|
||||
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, ...] = (
|
||||
Migration(version=1, name="baseline", sql=V1),
|
||||
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
|
||||
@@ -412,4 +556,7 @@ TESM_MIGRATIONS: tuple[Migration, ...] = (
|
||||
),
|
||||
Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen),
|
||||
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),
|
||||
Migration(version=11, name="vnc-ziel", sql=V11),
|
||||
)
|
||||
@@ -542,7 +542,8 @@ def list_devices(
|
||||
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,
|
||||
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,
|
||||
COALESCE(st.vnc_target, '') AS vnc_target
|
||||
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
|
||||
|
||||
@@ -16,8 +16,10 @@ eingelesen. Ein eigener Schreibpfad fuer Altdaten waere eine zweite Stelle, an
|
||||
der Fremdeingaben in die Datenbank gelangen -- und die zweite Stelle ist die,
|
||||
die niemand pflegt.
|
||||
|
||||
Uebersetzt werden Clients, Switche, Zugangsdaten, die DHCP-Konfiguration und
|
||||
die Freigabe-Zuordnungen. Was in der Datei sonst noch steht, wird nicht
|
||||
Uebersetzt werden Clients, Switche, Zugangsdaten, die DHCP-Konfiguration, die
|
||||
Freigabe-Zuordnungen und die Anbindung an das Verzeichnis (Server, Transport,
|
||||
Basis-DN, Anmeldeattribut, Bind-Konto samt Passwort, und die Zuordnung
|
||||
"diese AD-Gruppe wird Administrator"). Was in der Datei sonst noch steht, wird nicht
|
||||
stillschweigend verworfen, sondern im Bericht benannt -- damit sichtbar bleibt,
|
||||
was von Hand nachzuziehen ist.
|
||||
|
||||
@@ -43,9 +45,7 @@ from typing import Any
|
||||
LEGACY_KDF_ITERATIONS = 390000
|
||||
LEGACY_APP_TAG = "tesm"
|
||||
|
||||
#: Diese Abschnitte werden uebersetzt. "ldap" nur zum Teil -- daraus kommen die
|
||||
#: Freigabe-Zuordnungen, waehrend Servereinstellungen und Gruppen-Zuordnungen
|
||||
#: dort bleiben (siehe KNOWN_UNSUPPORTED).
|
||||
#: Diese Abschnitte werden uebersetzt.
|
||||
SUPPORTED = ("credentials", "switches", "devices", "dhcp", "ldap")
|
||||
|
||||
#: Was der alte Export sonst enthaelt. Wird gemeldet, nicht uebersetzt.
|
||||
@@ -56,13 +56,9 @@ KNOWN_UNSUPPORTED: dict[str, str] = {
|
||||
"Zweck. Konten neu anlegen."
|
||||
),
|
||||
"groups": "Gruppen und Rechte: die Rechteschlüssel wurden neu geschnitten.",
|
||||
# Nur noch der Teil, der wirklich nicht uebertragbar ist: Server, Bind-Konto
|
||||
# und die Gruppen-Zuordnung (dort zeigen die Namen auf Gruppen, deren
|
||||
# Rechteschluessel neu geschnitten wurden). Die Freigaben kommen mit.
|
||||
"ldap": (
|
||||
"Verzeichnisdienst: Server, Bind-Konto und die Gruppen-Zuordnung von Hand "
|
||||
"übertragen. Die Freigabe-Zuordnungen wurden übernommen."
|
||||
),
|
||||
# Der Abschnitt "ldap" wird uebersetzt (siehe _verzeichnis) -- was dabei
|
||||
# nicht mitkommt, benennt die Uebersetzung einzeln und genauer, als eine
|
||||
# pauschale Zeile hier es koennte.
|
||||
"nginx": "Webserver: Domain, Ports und Zertifikat von Hand übertragen.",
|
||||
"logs": "Auditlog: bleibt bewusst beim alten System.",
|
||||
"known_hosts": (
|
||||
@@ -285,6 +281,126 @@ def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> l
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _verzeichnis(data: Any, report: Report) -> dict[str, Any]:
|
||||
"""Die Anbindung an das Verzeichnis -- so weit sie sich abbilden laesst.
|
||||
|
||||
Zwei Stellen sind **nicht** 1:1, und beide werden benannt statt geraten:
|
||||
|
||||
* **Transport.** Der Vorgaenger kannte ``ldap_use_ssl`` (an/aus). Aus "an"
|
||||
wird LDAPS. Aus "aus" wird **StartTLS auf demselben Port** und nicht
|
||||
"unverschluesselt": diese Anwendung verlangt fuer eine unverschluesselte
|
||||
Verbindung einen ausdruecklichen Schalter, weil das Passwort dabei im
|
||||
Klartext ueber das Netz geht. Diesen Schalter setzt eine Uebernahme
|
||||
nicht fuer jemanden -- ein Active Directory beantwortet StartTLS auf 389
|
||||
ohnehin, und wenn nicht, sagt der Verbindungstest es sofort.
|
||||
* **Standard- und Anmeldegruppe.** Es gibt sie hier nicht als
|
||||
Einstellung: die Standardgruppe ist ein Kennzeichen an der Gruppe
|
||||
selbst, und eine erzwungene Anmeldegruppe kennt diese Anwendung nicht.
|
||||
Waren sie gesetzt, steht es im Bericht.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
alt = {str(k): v for k, v in (data.get("settings") or {}).items()}
|
||||
ergebnis: dict[str, Any] = {}
|
||||
einstellungen: dict[str, Any] = {}
|
||||
|
||||
def ja(schluessel: str) -> bool:
|
||||
return str(alt.get(schluessel) or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
server = str(alt.get("ldap_server") or "").strip()
|
||||
if server:
|
||||
einstellungen["ldap_host"] = server
|
||||
if str(alt.get("ldap_port") or "").strip().isdigit():
|
||||
einstellungen["ldap_port"] = int(str(alt["ldap_port"]).strip())
|
||||
if "ldap_enabled" in alt:
|
||||
einstellungen["ldap_enabled"] = ja("ldap_enabled")
|
||||
if str(alt.get("ldap_base_dn") or "").strip():
|
||||
einstellungen["ldap_base_dn"] = str(alt["ldap_base_dn"]).strip()
|
||||
if str(alt.get("ldap_user_filter_attr") or "").strip():
|
||||
einstellungen["ldap_user_attribute"] = str(alt["ldap_user_filter_attr"]).strip()
|
||||
|
||||
if "ldap_use_ssl" in alt:
|
||||
if ja("ldap_use_ssl"):
|
||||
einstellungen["ldap_security"] = "ldaps"
|
||||
else:
|
||||
einstellungen["ldap_security"] = "starttls"
|
||||
report.note(
|
||||
"Der Vorgänger hat unverschlüsselt verbunden. Übernommen als StartTLS auf "
|
||||
"demselben Port – eine unverschlüsselte Verbindung verlangt hier einen "
|
||||
"ausdrücklichen Schalter, den eine Übernahme nicht für Sie setzt. Bitte den "
|
||||
"Verbindungstest ausführen."
|
||||
)
|
||||
if ja("ldap_tls_skip_verify"):
|
||||
einstellungen["ldap_verify_cert"] = False
|
||||
report.note(
|
||||
"Die Prüfung des Serverzertifikats war abgeschaltet und bleibt es – sonst "
|
||||
"scheitert die Anmeldung nach dem Upgrade. Damit schützt die Verschlüsselung "
|
||||
"nur gegen Mitlesen, nicht gegen einen Man-in-the-Middle: bitte ein "
|
||||
"CA-Zertifikat hinterlegen und die Prüfung einschalten."
|
||||
)
|
||||
if einstellungen:
|
||||
ergebnis["settings"] = einstellungen
|
||||
|
||||
for schluessel, text in (
|
||||
(
|
||||
"ldap_default_group",
|
||||
"Standardgruppe {wert!r}: hier ist das ein Kennzeichen an der Gruppe selbst "
|
||||
"(Verwaltung -> Gruppen). Bitte dort setzen.",
|
||||
),
|
||||
(
|
||||
"ldap_required_login_group",
|
||||
"Erzwungene Anmeldegruppe {wert!r}: diese Anwendung kennt sie nicht. Wer sich "
|
||||
"anmelden darf, entscheidet die Gruppen-Zuordnung.",
|
||||
),
|
||||
):
|
||||
wert = str(alt.get(schluessel) or "").strip()
|
||||
if wert:
|
||||
report.note(text.format(wert=wert))
|
||||
|
||||
benutzer = str(data.get("bind_username") or alt.get("ldap_bind_dn") or "").strip()
|
||||
passwort = str(data.get("bind_password") or "")
|
||||
if benutzer:
|
||||
ergebnis["bind_username"] = benutzer
|
||||
ergebnis["bind_password"] = passwort
|
||||
if not passwort:
|
||||
report.note(
|
||||
f"Bind-Konto {benutzer!r} übernommen, aber ohne Passwort – es stand nicht in "
|
||||
"der Datei. Unter Verwaltung -> Verzeichnisdienst nachtragen."
|
||||
)
|
||||
|
||||
zuordnungen: list[dict[str, Any]] = []
|
||||
offen: list[str] = []
|
||||
for eintrag in _liste(data.get("group_mappings")):
|
||||
dn = str(eintrag.get("ad_group_dn") or "").strip()
|
||||
if not dn:
|
||||
continue
|
||||
name = str(eintrag.get("ad_group_name") or "").strip() or dn
|
||||
ziel = str(
|
||||
eintrag.get("app_group_name") or eintrag.get("app_group_id") or ""
|
||||
).strip()
|
||||
# Der Vorgaenger kannte den Sonderwert "admin" (bzw. "Admin" im
|
||||
# Export): diese AD-Gruppe macht Administratoren. Genau dafuer gibt es
|
||||
# hier seit 2.8.0 ein Ziel -- das Administratorkennzeichen.
|
||||
if ziel.lower() == "admin":
|
||||
zuordnungen.append(
|
||||
{"ad_group_dn": dn, "ad_group_name": name, "grants_admin": True}
|
||||
)
|
||||
continue
|
||||
if ziel:
|
||||
offen.append(f"{name} -> {ziel}")
|
||||
if zuordnungen:
|
||||
ergebnis["group_mappings"] = zuordnungen
|
||||
if offen:
|
||||
report.note(
|
||||
"Diese Gruppen-Zuordnungen zeigen auf lokale Gruppen, die nicht mit übernommen "
|
||||
"werden (die Rechteschlüssel sind neu geschnitten): "
|
||||
+ ", ".join(sorted(offen))
|
||||
+ ". Nach dem Anlegen der Gruppen unter Verwaltung -> Verzeichnisdienst "
|
||||
"nachtragen."
|
||||
)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _freigaben(data: Any, report: Report) -> list[dict[str, Any]]:
|
||||
"""Freigabe-Zuordnungen aus dem Abschnitt "ldap" des alten Exports.
|
||||
|
||||
@@ -441,6 +557,10 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
|
||||
if freigaben:
|
||||
payload["fileshare"] = freigaben
|
||||
|
||||
verzeichnis = _verzeichnis(legacy.get("ldap"), report)
|
||||
if verzeichnis:
|
||||
payload["directory"] = verzeichnis
|
||||
|
||||
dhcp, settings = _dhcp(legacy.get("dhcp"), report)
|
||||
if dhcp:
|
||||
payload["dhcp"] = dhcp
|
||||
@@ -676,8 +796,9 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
|
||||
continue
|
||||
payload[abschnitt] = [dict(row) for row in conn.execute(sql).fetchall()]
|
||||
|
||||
entschluesseln_bind = _entschluessler(root, report)
|
||||
if "credentials" in vorhanden:
|
||||
entschluesseln = _entschluessler(root, report)
|
||||
entschluesseln = entschluesseln_bind
|
||||
payload["credentials"] = [
|
||||
{
|
||||
"name": row["name"],
|
||||
@@ -693,8 +814,9 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
|
||||
else:
|
||||
report.note("Tabelle 'credentials' fehlt in der alten Datenbank.")
|
||||
|
||||
# Die Freigaben reisen im Abschnitt "ldap" -- genau dort, wo der alte
|
||||
# Export sie auch hat. So liest die Uebersetzung beide Wege gleich.
|
||||
# Der Abschnitt "ldap" -- genau so aufgebaut wie im alten Export, damit
|
||||
# die Uebersetzung beide Wege gleich liest.
|
||||
ldap: dict[str, Any] = {}
|
||||
if "ldap_fileshare_mappings" in vorhanden:
|
||||
freigaben = [
|
||||
dict(row)
|
||||
@@ -704,10 +826,68 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
|
||||
).fetchall()
|
||||
]
|
||||
if freigaben:
|
||||
payload["ldap"] = {"fileshare_mappings": freigaben}
|
||||
ldap["fileshare_mappings"] = freigaben
|
||||
else:
|
||||
report.note("Tabelle 'ldap_fileshare_mappings' fehlt in der alten Datenbank.")
|
||||
|
||||
if "settings" in vorhanden:
|
||||
# Nur anlegen, wenn etwas drinsteht: ein leerer Abschnitt sieht im
|
||||
# Bericht wie eine Uebernahme aus und ist keine.
|
||||
ldap_einstellungen = {
|
||||
str(row["key"]): str(row["value"])
|
||||
for row in conn.execute(
|
||||
"SELECT key, value FROM settings WHERE key LIKE 'ldap%'"
|
||||
).fetchall()
|
||||
}
|
||||
if ldap_einstellungen:
|
||||
ldap["settings"] = ldap_einstellungen
|
||||
|
||||
if "ldap_group_mappings" in vorhanden:
|
||||
# Der Sonderwert "admin" steht in derselben Spalte wie eine
|
||||
# Gruppen-Id; der Name dahinter ist das, was ueber die
|
||||
# Systemgrenze traegt.
|
||||
gruppen_da = "groups" in vorhanden
|
||||
zuordnungen = [
|
||||
{
|
||||
"ad_group_dn": str(row["ad_group_dn"]),
|
||||
"ad_group_name": str(row["ad_group_name"]),
|
||||
"app_group_name": str(row["ziel"] or ""),
|
||||
}
|
||||
for row in conn.execute(
|
||||
"SELECT m.ad_group_dn, m.ad_group_name, CASE "
|
||||
" WHEN m.app_group_id = 'admin' THEN 'admin' "
|
||||
+ (" ELSE g.name " if gruppen_da else " ELSE NULL ")
|
||||
+ "END AS ziel FROM ldap_group_mappings m"
|
||||
+ (
|
||||
" LEFT JOIN groups g ON g.id = m.app_group_id"
|
||||
if gruppen_da
|
||||
else ""
|
||||
)
|
||||
).fetchall()
|
||||
]
|
||||
if zuordnungen:
|
||||
ldap["group_mappings"] = zuordnungen
|
||||
|
||||
# Das Bind-Konto: erst das Dienstkonto (so liest es auch der alte
|
||||
# Export), sonst die beiden Einstellungen aus aelteren Fassungen.
|
||||
if "service_accounts" in vorhanden:
|
||||
konto = _eine(
|
||||
conn,
|
||||
"SELECT username, password FROM service_accounts WHERE purpose='ldap_bind'",
|
||||
)
|
||||
if konto is not None:
|
||||
ldap["bind_username"] = str(konto["username"] or "")
|
||||
ldap["bind_password"] = entschluesseln_bind(str(konto["password"] or ""))
|
||||
if not ldap.get("bind_username"):
|
||||
aus_einstellungen = (ldap.get("settings") or {}).get("ldap_bind_dn") or ""
|
||||
if aus_einstellungen:
|
||||
ldap["bind_username"] = str(aus_einstellungen)
|
||||
ldap["bind_password"] = entschluesseln_bind(
|
||||
str((ldap.get("settings") or {}).get("ldap_bind_password") or "")
|
||||
)
|
||||
if ldap:
|
||||
payload["ldap"] = ldap
|
||||
|
||||
dhcp = _alt_dhcp(conn, vorhanden)
|
||||
if dhcp:
|
||||
payload["dhcp"] = dhcp
|
||||
@@ -723,6 +903,10 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
|
||||
for abschnitt, inhalt in payload.items():
|
||||
if abschnitt == "ldap" and isinstance(inhalt, dict):
|
||||
report.took("Freigaben", len(_liste(inhalt.get("fileshare_mappings"))))
|
||||
report.took("Verzeichniseinstellungen", len(inhalt.get("settings") or {}))
|
||||
report.took("Gruppen-Zuordnungen", len(_liste(inhalt.get("group_mappings"))))
|
||||
if inhalt.get("bind_username"):
|
||||
report.took("Bind-Konto", 1)
|
||||
continue
|
||||
report.took(abschnitt, len(inhalt) if isinstance(inhalt, list) else 1)
|
||||
return payload, report
|
||||
|
||||
@@ -507,6 +507,23 @@ class SweepReport:
|
||||
duration_ms: int = 0
|
||||
|
||||
|
||||
def merke_vnc(
|
||||
conn: sqlite3.Connection, device_id: int, offen: bool, ziel: str = ""
|
||||
) -> None:
|
||||
"""Haelt fest, ob dort ein VNC-Server antwortet -- und unter welchem Ziel.
|
||||
|
||||
Das Ziel gehoert dazu, weil die Statusuebersicht die Verbindung unmittelbar
|
||||
verlinkt. Es aus dem Namen neu herzuleiten hiesse, je Kachel und
|
||||
Seitenaufbau eine Namensaufloesung zu versuchen; hier steht es einmal
|
||||
geschrieben, von dem, der ohnehin gefragt hat.
|
||||
"""
|
||||
conn.execute(
|
||||
"UPDATE device_status SET vnc_open=?, vnc_checked_at=?, vnc_target=? "
|
||||
"WHERE device_id=?",
|
||||
(1 if offen else 0, _now(), ziel, device_id),
|
||||
)
|
||||
|
||||
|
||||
def run_sweep(
|
||||
*,
|
||||
database: Any,
|
||||
@@ -514,13 +531,21 @@ def run_sweep(
|
||||
keystore: Any,
|
||||
license_allows_auto_restart: bool,
|
||||
failures_before_restart: int = DEFAULT_FAILURES_BEFORE_RESTART,
|
||||
vnc_port: int = 0,
|
||||
) -> SweepReport:
|
||||
"""Ein vollstaendiger Ueberwachungsdurchlauf.
|
||||
|
||||
Die Pruefungen laufen parallel (I/O-gebunden), die Datenbankschreibvorgaenge
|
||||
danach in **einer** Transaktion -- so gibt es keinen Schreib-Wettlauf und
|
||||
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()
|
||||
started = time.monotonic()
|
||||
|
||||
@@ -531,15 +556,29 @@ def run_sweep(
|
||||
report.duration_ms = int((time.monotonic() - started) * 1000)
|
||||
return report
|
||||
|
||||
def pruefen(
|
||||
device: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], PingResult, tuple[bool, str] | 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)
|
||||
|
||||
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]] = []
|
||||
with database.session() as conn:
|
||||
with database.transaction(conn):
|
||||
for device, result in results:
|
||||
for device, result, vnc_offen in results:
|
||||
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])
|
||||
if result.ok:
|
||||
report.online += 1
|
||||
else:
|
||||
|
||||
@@ -30,14 +30,37 @@ from .ssh import SshError, open_transport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Steuerzeichen eines Terminals -- ANSI-Sequenzen, Glocke, Einzelbefehle.
|
||||
#:
|
||||
#: Ein ArubaOS-Switch an einem Pseudoterminal zeichnet seine Zeile neu und
|
||||
#: haengt dabei Sequenzen wie ``ESC [ K`` ("bis Zeilenende loeschen") an. Im
|
||||
#: Terminal sieht man davon **nichts** -- und genau das war der Fehler, der
|
||||
#: Konsole und PoE-Neustart lahmgelegt hat: der Prompt stand da, aber hinter
|
||||
#: ihm standen drei unsichtbare Bytes, und das verankerte Muster traf nicht
|
||||
#: mehr. Im Protokoll las man "kein Prompt", waehrend der Prompt im Text
|
||||
#: stand. Deshalb kommen die Steuerzeichen weg, bevor irgendetwas gesucht
|
||||
#: wird -- das macht zugleich die Ausgabe in der Konsole lesbar.
|
||||
ANSI_RE = re.compile(rb"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b[@-Z\\-_]|[\x07\x00]")
|
||||
|
||||
#: Erkennt Operator- (``>``) und privilegierten Modus (``#``) sowie den
|
||||
#: Konfigurationsmodus (``switch(config)#``).
|
||||
PROMPT_RE = re.compile(rb"[\r\n][^\r\n]{0,80}?[>#]\s?$")
|
||||
#:
|
||||
#: Zwei Lockerungen gegenueber der ersten Fassung, beide aus dem Betrieb:
|
||||
#: der Prompt darf am Anfang des Puffers stehen (nicht nur nach einem
|
||||
#: Zeilenumbruch -- so kommt er nach einem geleerten Puffer), und hinter ihm
|
||||
#: darf beliebiger Leerraum stehen (nicht genau eines).
|
||||
PROMPT_RE = re.compile(rb"(?:^|[\r\n])[^\r\n]{0,120}?[>#]\s*$")
|
||||
PAGER_RE = re.compile(rb"(--\s?MORE\s?--|Press any key to continue)", re.IGNORECASE)
|
||||
ERROR_RE = re.compile(
|
||||
r"(invalid input|unknown command|incomplete|ambiguous|not supported|% *error)", re.IGNORECASE
|
||||
)
|
||||
|
||||
#: Der Weg nach draussen -- zweimal "exit" fuehrt aus jedem Modus in den
|
||||
#: privilegierten zurueck, das dritte beendet die Sitzung. Genau so macht es
|
||||
#: das bewaehrte Skript der Vorgaengerfassung.
|
||||
ABMELDUNG = ("exit", "exit", "exit")
|
||||
ABMELDE_PAUSE = 0.15
|
||||
|
||||
READ_CHUNK = 65536
|
||||
DEFAULT_COMMAND_TIMEOUT = 20
|
||||
POE_SETTLE_SECONDS = 2
|
||||
@@ -104,7 +127,42 @@ class SwitchCli:
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
for closer in (self._channel, self._transport):
|
||||
"""Meldet sich ab und legt dann erst auf.
|
||||
|
||||
Der entscheidende Teil, und der einzige, den das ``expect``-Skript der
|
||||
Fassung 1.2.8 hatte und diese Fassung nicht: ``exit`` bis heraus aus
|
||||
dem Konfigurationsmodus, dann ``exit``, und auf die Frage
|
||||
"Do you want to log out (y/n)?" ein ``y``.
|
||||
|
||||
Warum das mehr ist als Hoeflichkeit: ArubaOS erlaubt nur eine Handvoll
|
||||
gleichzeitiger SSH-Sitzungen (``show ip ssh``, Vorgabe fuenf) und
|
||||
raeumt eine weggebrochene Verbindung **nicht sofort** ab. Wer die
|
||||
Verbindung nur fallen laesst, belegt den Platz weiter. Nach ein paar
|
||||
Vorgaengen -- ein Ueberwachungsdurchlauf mit mehreren Geraeten am
|
||||
selben Switch reicht -- sind alle Plaetze belegt. Danach kommt eine
|
||||
neue Sitzung noch durch Anmeldung und Authentisierung, bekommt aber
|
||||
**keine Shell**: kein Prompt, keine Antwort, nichts. Im Betrieb sieht
|
||||
das aus wie ein stummer Switch, und der Portzyklus passiert nicht.
|
||||
|
||||
Fehler werden hier geschluckt: eine Abmeldung, die nicht klappt, darf
|
||||
das Ergebnis des Vorgangs nicht umwerfen -- aufgelegt wird ohnehin.
|
||||
"""
|
||||
kanal = self._channel
|
||||
if kanal is not None:
|
||||
try:
|
||||
for zeile in ABMELDUNG:
|
||||
kanal.send(zeile + "\r\n")
|
||||
# Kurz warten, damit der Switch die Zeile verarbeitet --
|
||||
# auf die Antwort zu warten wuerde beim Abmelden nur
|
||||
# Zeitlimits sammeln.
|
||||
time.sleep(ABMELDE_PAUSE)
|
||||
# "Do you want to log out (y/n)?"
|
||||
kanal.send("y\r\n")
|
||||
time.sleep(ABMELDE_PAUSE)
|
||||
except Exception: # noqa: BLE001 - die Verbindung ist ohnehin am Ende
|
||||
logger.debug("Abmeldung von %s nicht mehr moeglich", self.host)
|
||||
|
||||
for closer in (kanal, self._transport):
|
||||
try:
|
||||
if closer is not None:
|
||||
closer.close()
|
||||
@@ -116,28 +174,62 @@ class SwitchCli:
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _read_until_prompt(self, *, timeout: int, initial: bool = False) -> str:
|
||||
"""Liest bis zum Prompt. Ohne Prompt ist es ein Fehler, keine Antwort.
|
||||
|
||||
Bis 2.8.2 lief diese Schleife nach dem Zeitlimit einfach aus und gab
|
||||
zurueck, was bis dahin gekommen war -- oft nichts. Der Aufrufer sah
|
||||
eine leere Antwort und machte weiter: ``configure terminal`` ins
|
||||
Nichts, ``no power-over-ethernet`` ins Nichts, und weil die
|
||||
Zustandsabfrage dann "unknown" ergab und "unknown" als
|
||||
"nicht widersprochen" durchging, meldete TESM am Ende **Erfolg** --
|
||||
waehrend am Switch nichts geschaltet wurde. Genau das war im Betrieb
|
||||
zu sehen: der Client blieb durchgehend erreichbar, das Protokoll sagte
|
||||
"durchgefuehrt".
|
||||
|
||||
Ein Kommando ohne Prompt ist ein Kommando ohne Antwort. Das ist ein
|
||||
Fehlschlag und wird als solcher gemeldet -- mit dem, was zuletzt
|
||||
hereinkam, denn daran erkennt man, wo die Sitzung steht.
|
||||
"""
|
||||
assert self._channel is not None
|
||||
deadline = time.monotonic() + timeout
|
||||
collected = b""
|
||||
# Zwei Puffer: das Rohe kommt herein, gesucht wird im Sichtbaren.
|
||||
# Ohne diese Trennung entscheiden unsichtbare Steuerzeichen darueber,
|
||||
# ob ein Prompt erkannt wird.
|
||||
sichtbar = b""
|
||||
prompt_gesehen = False
|
||||
while time.monotonic() < deadline:
|
||||
if self._channel.recv_ready():
|
||||
chunk = self._channel.recv(READ_CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
collected += chunk
|
||||
if PAGER_RE.search(collected[-200:]):
|
||||
sichtbar = ANSI_RE.sub(b"", sichtbar + chunk)
|
||||
if PAGER_RE.search(sichtbar[-300:]):
|
||||
self._channel.send(" ")
|
||||
collected = PAGER_RE.sub(b"", collected)
|
||||
sichtbar = PAGER_RE.sub(b"", sichtbar)
|
||||
continue
|
||||
if PROMPT_RE.search(collected[-200:]):
|
||||
if PROMPT_RE.search(sichtbar[-300:]):
|
||||
prompt_gesehen = True
|
||||
break
|
||||
# Der Login-Banner mancher Geraete verlangt eine Bestaetigung.
|
||||
if initial and collected.rstrip().endswith(b"continue"):
|
||||
if initial and sichtbar.rstrip().endswith(b"continue"):
|
||||
self._channel.send("\r\n")
|
||||
else:
|
||||
time.sleep(0.05)
|
||||
text = collected.decode("utf-8", errors="replace")
|
||||
text = sichtbar.decode("utf-8", errors="replace")
|
||||
self.transcript.append(text)
|
||||
if not prompt_gesehen:
|
||||
letzte = " ".join(text.split())[-200:]
|
||||
if initial:
|
||||
raise SshError(
|
||||
f"{self.host} hat sich nach der Anmeldung nicht gemeldet "
|
||||
f"(kein Prompt innerhalb von {timeout}s)."
|
||||
+ (f" Zuletzt kam: {letzte}" if letzte else " Es kam nichts.")
|
||||
)
|
||||
raise SshError(
|
||||
f"{self.host} hat auf das Kommando nicht geantwortet "
|
||||
f"(kein Prompt innerhalb von {timeout}s)."
|
||||
+ (f" Zuletzt kam: {letzte}" if letzte else " Es kam nichts.")
|
||||
)
|
||||
return text
|
||||
|
||||
def send(
|
||||
@@ -212,6 +304,17 @@ class SwitchCli:
|
||||
)
|
||||
result.transcript = self.full_transcript
|
||||
return result
|
||||
if before == "enabled" and off_state == "unknown":
|
||||
# Vorher lesbar, nachher nicht: dann ist nicht die Firmware
|
||||
# unbekannt, sondern die Sitzung aus dem Tritt. Das als Erfolg
|
||||
# zu zaehlen war der Fehler, der einen Neustart meldete, den
|
||||
# es nicht gab.
|
||||
result.error = (
|
||||
"Der Zustand des Ports ließ sich nach dem Abschalten nicht mehr "
|
||||
"lesen – vorher ging es. Der Portzyklus ist damit nicht belegt."
|
||||
)
|
||||
result.transcript = self.full_transcript
|
||||
return result
|
||||
result.add(f"Zustand im ausgeschalteten Zustand: {off_state}")
|
||||
|
||||
self.set_poe(port, enabled=True)
|
||||
@@ -224,6 +327,17 @@ class SwitchCli:
|
||||
result.transcript = self.full_transcript
|
||||
return result
|
||||
|
||||
if before == "unknown" and off_state == "unknown" and after == "unknown":
|
||||
# Kein einziger Zustand lesbar: die Kommandos hat der Switch
|
||||
# zwar mit einem Prompt bestaetigt (sonst waere die Sitzung
|
||||
# oben abgebrochen), aber belegt ist der Zyklus nicht. Das
|
||||
# gehoert in die Meldung -- "durchgefuehrt" waere zu viel
|
||||
# gesagt.
|
||||
result.add(
|
||||
"Der Zustand war nicht lesbar – die Kommandos hat der Switch "
|
||||
"angenommen, bestätigt ist der Zyklus damit nicht."
|
||||
)
|
||||
|
||||
result.ok = True
|
||||
except SshError as exc:
|
||||
result.error = str(exc)
|
||||
|
||||
@@ -62,6 +62,14 @@ CATEGORIES: tuple[Category, ...] = (
|
||||
),
|
||||
Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"),
|
||||
Category("fileshare", "Freigabe-Zuordnungen", "Verzeichnisgruppe zu UNC-Pfad"),
|
||||
Category(
|
||||
"directory",
|
||||
"Verzeichnisdienst",
|
||||
"LDAP-Anbindung, Bind-Konto und Gruppen-Zuordnungen. Das Passwort des "
|
||||
"Bind-Kontos nur mit \"Geheimnisse einschliessen\".",
|
||||
admin_only=True,
|
||||
sensitive=True,
|
||||
),
|
||||
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
|
||||
Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True),
|
||||
Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True),
|
||||
@@ -186,6 +194,49 @@ def build_export(
|
||||
}
|
||||
for row in Database.all(conn, "SELECT * FROM groups WHERE deleted_at IS NULL")
|
||||
]
|
||||
if "directory" in wanted:
|
||||
# Die Anbindung an das Verzeichnis war das einzige Stueck Konfiguration,
|
||||
# das man nach einer Wiederherstellung von Hand nachbauen musste --
|
||||
# inklusive Bind-Passwort, das dabei jemand aus einem Kennwortspeicher
|
||||
# holen muss. Es gehoert zur Anlage wie die Netzwerkeinstellung.
|
||||
from tesm_core.auth import directory as verzeichnis
|
||||
|
||||
bind_benutzer, bind_passwort = verzeichnis.bind_credentials(conn)
|
||||
gesetzt = {
|
||||
str(row["key"]): str(row["value"])
|
||||
for row in Database.all(conn, "SELECT key, value FROM settings")
|
||||
}
|
||||
payload["directory"] = {
|
||||
# Nur die Schluessel der Anbindung, und nur die, die wirklich
|
||||
# gesetzt sind: ein Vorgabewert in der Datei sieht aus wie eine
|
||||
# Entscheidung und ueberschreibt beim Einlesen eine echte.
|
||||
"settings": {
|
||||
spec.key: gesetzt[spec.key] for spec in verzeichnis.SETTINGS
|
||||
if spec.key in gesetzt
|
||||
},
|
||||
"bind_username": bind_benutzer,
|
||||
# Ohne "Geheimnisse einschliessen" reist das Passwort nicht mit.
|
||||
# Der Benutzername schon: er ist keine Geheimnis, und ohne ihn
|
||||
# muesste man die halbe Anbindung doch wieder tippen.
|
||||
"bind_password": bind_passwort if include_secrets else "",
|
||||
# Die Zuordnung zeigt auf eine Gruppe **oder** auf das
|
||||
# Administratorkennzeichen. Ueber eine Systemgrenze hinweg ist eine
|
||||
# Gruppen-Id nichts wert -- der Name ist es.
|
||||
"group_mappings": [
|
||||
{
|
||||
"ad_group_dn": str(row["ad_group_dn"]),
|
||||
"ad_group_name": str(row["ad_group_name"]),
|
||||
"group_name": str(row["group_name"] or ""),
|
||||
"grants_admin": bool(row["grants_admin"]),
|
||||
}
|
||||
for row in Database.all(
|
||||
conn,
|
||||
"SELECT m.ad_group_dn, m.ad_group_name, m.grants_admin, g.name AS group_name "
|
||||
"FROM ldap_group_mappings m "
|
||||
"LEFT JOIN groups g ON g.id = m.app_group_id AND g.deleted_at IS NULL",
|
||||
)
|
||||
],
|
||||
}
|
||||
if "settings" in wanted:
|
||||
payload["settings"] = {
|
||||
str(row["key"]): str(row["value"])
|
||||
@@ -704,6 +755,63 @@ def apply_import(
|
||||
)
|
||||
bump("signatures", "created")
|
||||
|
||||
if "directory" in wanted:
|
||||
from tesm_core.auth import directory as verzeichnis
|
||||
|
||||
abschnitt = payload.get("directory") or {}
|
||||
bekannt = {spec.key for spec in verzeichnis.SETTINGS}
|
||||
for key, value in (abschnitt.get("settings") or {}).items():
|
||||
if str(key) not in bekannt:
|
||||
# Ein fremder Schluessel in diesem Abschnitt gehoert nicht hier
|
||||
# hin; die Kategorie "Einstellungen" nimmt alles andere.
|
||||
bump("directory", "skipped")
|
||||
continue
|
||||
set_setting(str(key), value)
|
||||
bump("directory", "updated")
|
||||
|
||||
benutzer = str(abschnitt.get("bind_username") or "").strip()
|
||||
passwort = str(abschnitt.get("bind_password") or "")
|
||||
if benutzer:
|
||||
# Ohne Passwort bleibt ein vorhandenes stehen -- genau dafuer nimmt
|
||||
# store_bind_credentials None. Eine Sicherung ohne Geheimnisse darf
|
||||
# das Passwort nicht loeschen.
|
||||
verzeichnis.store_bind_credentials(conn, benutzer, passwort or None)
|
||||
bump("directory", "updated")
|
||||
|
||||
for eintrag in abschnitt.get("group_mappings") or []:
|
||||
dn = str(eintrag.get("ad_group_dn") or "").strip()
|
||||
if not dn:
|
||||
bump("directory", "skipped")
|
||||
continue
|
||||
kennzeichen = bool(eintrag.get("grants_admin"))
|
||||
gruppe = None
|
||||
if not kennzeichen:
|
||||
name = str(eintrag.get("group_name") or "").strip()
|
||||
gruppe = Database.value(
|
||||
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (name,)
|
||||
)
|
||||
if gruppe is None:
|
||||
# Eine Zuordnung auf eine Gruppe, die es hier nicht gibt,
|
||||
# waere eine Zuordnung ins Leere: beim naechsten Login
|
||||
# bekaeme das Konto nichts, und niemand wuesste warum.
|
||||
bump("directory", "skipped")
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT INTO ldap_group_mappings "
|
||||
"(ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
|
||||
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn) DO UPDATE SET "
|
||||
"ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id, "
|
||||
"grants_admin=excluded.grants_admin",
|
||||
(
|
||||
dn,
|
||||
str(eintrag.get("ad_group_name") or dn),
|
||||
int(gruppe) if gruppe is not None else None,
|
||||
int(kennzeichen),
|
||||
stamp,
|
||||
),
|
||||
)
|
||||
bump("directory", "created")
|
||||
|
||||
if "settings" in wanted:
|
||||
for key, value in (payload.get("settings") or {}).items():
|
||||
if str(key).startswith("license_"):
|
||||
|
||||
@@ -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.",
|
||||
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 --------------------------------------------------------------- #
|
||||
SettingSpec("dhcp_domain", "local", "str", "Domainname", section="dhcp"),
|
||||
SettingSpec(
|
||||
|
||||
@@ -84,21 +84,6 @@
|
||||
<a class="btn btn--sm btn--ghost" href="{{ url_for('devices.detail', device_id=tile.id) }}">
|
||||
Details
|
||||
</a>
|
||||
{% if tile.restart_methods %}
|
||||
{% set method = tile.restart_methods[0] %}
|
||||
<form method="post" data-no-guard
|
||||
action="{{ url_for('devices.restart_device', device_id=tile.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="method" value="{{ method.key }}">
|
||||
<button class="btn btn--sm" type="submit" title="{{ method.hint }}"
|
||||
data-busy-label="Startet ..."
|
||||
data-confirm="{{ tile.name }} über {{ method.label }} neu starten? {{ method.hint }}"
|
||||
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
|
||||
data-confirm-style="primary">
|
||||
{{ icon('power', size=14) }} {{ method.label }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if current_user.has('devices.write') and tile.state == 'disabled' %}
|
||||
<form method="post" action="{{ url_for('devices.toggle_active', device_id=tile.id) }}"
|
||||
data-no-guard>
|
||||
@@ -108,6 +93,42 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# Die beiden Eingriffe stehen unten und immer an derselben
|
||||
Stelle: links der Bildschirm, rechts der Neustart. Wer
|
||||
eine Reihe Kacheln abarbeitet, trifft denselben Knopf,
|
||||
ohne hinzusehen -- und verwechselt ihn nicht mit dem
|
||||
anderen. #}
|
||||
{% 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 }})">
|
||||
{{ icon('monitor', size=14) }} VNC
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="tile__spacer" aria-hidden="true"></span>
|
||||
{% endif %}
|
||||
{% if tile.restart_methods %}
|
||||
{% set method = tile.restart_methods[0] %}
|
||||
<form method="post" data-no-guard
|
||||
action="{{ url_for('devices.restart_device', device_id=tile.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="method" value="{{ method.key }}">
|
||||
<button class="btn btn--sm" type="submit" title="{{ method.hint }}"
|
||||
data-busy-label="Startet ..."
|
||||
data-confirm="{{ tile.name }} über {{ method.label }} neu starten? {{ method.hint }}"
|
||||
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
|
||||
data-confirm-style="primary">
|
||||
{{ icon('power', size=14) }} {{ method.label }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
@@ -16,6 +16,15 @@
|
||||
{{ icon('terminal', size=15) }} Konsole
|
||||
</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 }})">
|
||||
{{ icon('monitor', size=15) }} VNC
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if restart_methods %}
|
||||
{% for method in restart_methods %}
|
||||
<form method="post" style="display:inline"
|
||||
@@ -225,6 +234,25 @@
|
||||
<dt>Kategorie</dt><dd>{{ device.category or '–' | safe }}</dd>
|
||||
<dt>Standort</dt><dd>{{ device.location or '–' | safe }}</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">
|
||||
Öffnet den Viewer unmittelbar, sofern er <code>vnc://</code> übernimmt.
|
||||
Sonst die
|
||||
<a href="{{ url_for('devices.vnc_connection', device_id=device.id) }}">
|
||||
Verbindungsdatei</a> – sie enthält nur Ziel und Namen.
|
||||
</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>Angelegt</dt><dd>{{ device.created_at | dt }}</dd>
|
||||
<dt>Geändert</dt>
|
||||
|
||||
@@ -60,9 +60,18 @@
|
||||
<dd>
|
||||
{% if state.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 %}
|
||||
{% 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 %}
|
||||
</dd>
|
||||
<dt>Lizenzserver</dt>
|
||||
|
||||
@@ -46,11 +46,46 @@
|
||||
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
|
||||
checked=values.dashboard_public,
|
||||
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 class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
</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">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
@@ -189,7 +224,7 @@
|
||||
</section>
|
||||
|
||||
{% 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()) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ from types import FrameType
|
||||
from tesm_core.extension import core
|
||||
|
||||
from . import create_app
|
||||
from .services import inventory, monitor
|
||||
from .services import inventory, monitor, vnc
|
||||
|
||||
logger = logging.getLogger("tesm.monitor")
|
||||
|
||||
@@ -82,6 +82,7 @@ class MonitorDaemon:
|
||||
keystore=self.extension.keystore,
|
||||
license_allows_auto_restart=self._auto_restart_allowed(),
|
||||
failures_before_restart=self._failures_before_restart(),
|
||||
vnc_port=vnc.port_aus_einstellungen(self.extension),
|
||||
)
|
||||
logger.info(
|
||||
"Durchlauf: %s geprüft, %s online, %s offline, %s Neustarts (%s erfolgreich), "
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-core"
|
||||
version = "2.8.0"
|
||||
version = "2.8.4"
|
||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -102,6 +102,14 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
),
|
||||
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
|
||||
# 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(
|
||||
"license_server_ca",
|
||||
"",
|
||||
|
||||
@@ -26,6 +26,7 @@ import re
|
||||
import socket
|
||||
import sqlite3
|
||||
import ssl
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
@@ -162,19 +163,82 @@ def _tls_object(config: dict[str, Any]):
|
||||
|
||||
|
||||
def _server(config: dict[str, Any]):
|
||||
"""Das Server-Objekt -- so sparsam wie moeglich.
|
||||
|
||||
``get_info`` entscheidet, was ldap3 beim Verbinden **zusaetzlich** holt.
|
||||
Mit ``ALL`` ist das die Beschreibung des Verzeichnisses *und sein ganzes
|
||||
Schema*: bei einem Active Directory sind das Tausende Attribut- und
|
||||
Klassendefinitionen, mehrere Megabyte, bei jeder Verbindung neu. Fuer eine
|
||||
Anmeldung braucht davon nichts -- gelesen wird hoechstens der
|
||||
``defaultNamingContext``, wenn kein Basis-DN eingetragen ist, und dafuer
|
||||
genuegt ``DSA`` (nur die Serverinformation, ein Datensatz).
|
||||
|
||||
Liegt der Domain-Controller in einem anderen Netz, ist genau das der
|
||||
Unterschied zwischen "geht sofort" und "dauert manchmal".
|
||||
"""
|
||||
from ldap3 import Server
|
||||
|
||||
security = str(config["ldap_security"])
|
||||
braucht_basis = not str(config.get("ldap_base_dn") or "").strip()
|
||||
return Server(
|
||||
str(config["ldap_host"]),
|
||||
port=int(config["ldap_port"]),
|
||||
use_ssl=security == "ldaps",
|
||||
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
|
||||
get_info="ALL",
|
||||
get_info="DSA" if braucht_basis else "NO_INFO",
|
||||
connect_timeout=int(config["ldap_timeout"]),
|
||||
)
|
||||
|
||||
|
||||
#: Fehler, die nur "gerade jetzt nicht" bedeuten -- nicht "falsches Passwort".
|
||||
#:
|
||||
#: Die Liste stammt aus der Fassung 1.2.8, wo dieses Problem schon einmal
|
||||
#: geloest war. Der Kommentar dort beschreibt es genauer, als ich es koennte:
|
||||
#: ein Domain-Controller in einem anderen, langsameren Netzsegment -- die
|
||||
#: allererste Verbindung dorthin ist oft spuerbar langsamer als jede folgende,
|
||||
#: und der erste Login schlug mit "Benutzer/Passwort ungueltig" fehl, waehrend
|
||||
#: ein sofortiger zweiter Versuch klappte.
|
||||
TRANSIENTE_FEHLER: tuple[type[Exception], ...] = ()
|
||||
|
||||
|
||||
def _transiente_fehler() -> tuple[type[Exception], ...]:
|
||||
"""Die Ausnahmeklassen von ldap3, die einen Wiederholversuch verdienen."""
|
||||
global TRANSIENTE_FEHLER
|
||||
if TRANSIENTE_FEHLER:
|
||||
return TRANSIENTE_FEHLER
|
||||
from ldap3.core.exceptions import (
|
||||
LDAPResponseTimeoutError,
|
||||
LDAPSocketOpenError,
|
||||
LDAPSocketReceiveError,
|
||||
LDAPSocketSendError,
|
||||
)
|
||||
|
||||
TRANSIENTE_FEHLER = (
|
||||
LDAPSocketOpenError,
|
||||
LDAPSocketReceiveError,
|
||||
LDAPSocketSendError,
|
||||
LDAPResponseTimeoutError,
|
||||
)
|
||||
return TRANSIENTE_FEHLER
|
||||
|
||||
|
||||
def mit_wiederholung(aufbau: Any) -> Any:
|
||||
"""Genau ein zweiter Versuch -- und nur bei einem Netzfehler.
|
||||
|
||||
Nicht bei einer abgelehnten Anmeldung: ein Tippfehler im Passwort wuerde
|
||||
sonst den Sperrzaehler des Kontos zweimal statt einmal hochzaehlen. Das
|
||||
ist die Regel aus 1.2.8, und sie ist richtig.
|
||||
"""
|
||||
try:
|
||||
return aufbau()
|
||||
except _transiente_fehler() as fehler:
|
||||
logger.info(
|
||||
"Verzeichnisdienst antwortete nicht (%s) -- ein zweiter Versuch",
|
||||
type(fehler).__name__,
|
||||
)
|
||||
return aufbau()
|
||||
|
||||
|
||||
def _connect(config: dict[str, Any], user: str, password: str):
|
||||
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
|
||||
from ldap3 import Connection
|
||||
@@ -192,6 +256,12 @@ def _connect(config: dict[str, Any], user: str, password: str):
|
||||
auto_bind=False,
|
||||
raise_exceptions=False,
|
||||
receive_timeout=int(config["ldap_timeout"]),
|
||||
# Ein Active Directory antwortet auf eine Suche ab der Domaenenwurzel
|
||||
# mit Verweisen auf andere Controller. ldap3 folgt ihnen von sich aus
|
||||
# -- und baut dafuer neue Verbindungen auf, womoeglich in ein anderes
|
||||
# Netz. Fuer eine Anmeldung ist das nicht nur unnoetig, sondern die
|
||||
# Quelle genau der Wartezeiten, die niemand erklaeren kann.
|
||||
auto_referrals=False,
|
||||
)
|
||||
if security == "starttls":
|
||||
if not connection.open():
|
||||
@@ -253,8 +323,19 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
||||
if not config["ldap_verify_cert"]:
|
||||
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprüft")
|
||||
|
||||
begonnen = time.monotonic()
|
||||
zeiten: list[str] = []
|
||||
|
||||
def gemessen(name: str, seit: float) -> float:
|
||||
"""Haelt eine Stufe mit ihrer Dauer fest und gibt die neue Marke."""
|
||||
jetzt = time.monotonic()
|
||||
zeiten.append(f"{name} {int((jetzt - seit) * 1000)}ms")
|
||||
return jetzt
|
||||
|
||||
try:
|
||||
service_conn, bound = _connect(config, bind_user, bind_password)
|
||||
service_conn, bound = mit_wiederholung(
|
||||
lambda: _connect(config, bind_user, bind_password)
|
||||
)
|
||||
except (PermissionError, ConnectionError) as exc:
|
||||
logger.error("Verzeichnisdienst: %s", exc)
|
||||
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
|
||||
@@ -271,8 +352,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
||||
warnings=tuple(warnings),
|
||||
)
|
||||
|
||||
marke = gemessen("Dienstbindung", begonnen)
|
||||
try:
|
||||
base = _base_dn(service_conn, config)
|
||||
marke = gemessen("Basis-DN", marke)
|
||||
if not base:
|
||||
return DirectoryResult(
|
||||
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
|
||||
@@ -288,8 +371,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
||||
attributes=list(dict.fromkeys(wanted)),
|
||||
size_limit=2,
|
||||
)
|
||||
marke = gemessen("Benutzersuche", marke)
|
||||
entries = list(service_conn.entries)
|
||||
if len(entries) != 1:
|
||||
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
|
||||
return DirectoryResult("invalid", warnings=tuple(warnings))
|
||||
|
||||
entry = entries[0]
|
||||
@@ -300,9 +385,18 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
||||
|
||||
# Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
|
||||
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
|
||||
user_conn, user_bound = _connect(config, dn, password)
|
||||
#
|
||||
# Mit Wiederholversuch, und genau hier zeigte sich das Symptom: bei
|
||||
# einem Domain-Controller in einem anderen Netz schlug der erste
|
||||
# Versuch mit "Benutzer/Passwort ungueltig" fehl, der zweite gleich
|
||||
# darauf klappte. Wiederholt wird nur bei einem Netzfehler -- eine
|
||||
# abgelehnte Anmeldung bleibt abgelehnt, sonst zaehlt ein Tippfehler
|
||||
# den Sperrzaehler des Kontos zweimal hoch.
|
||||
user_conn, user_bound = mit_wiederholung(lambda: _connect(config, dn, password))
|
||||
marke = gemessen("Passwortpruefung", marke)
|
||||
try:
|
||||
if not user_bound:
|
||||
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
|
||||
return DirectoryResult("invalid", warnings=tuple(warnings))
|
||||
finally:
|
||||
try:
|
||||
@@ -313,6 +407,7 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
||||
group_dns: tuple[str, ...] = ()
|
||||
if config["ldap_group_sync"]:
|
||||
group_dns = _resolve_groups(service_conn, base, dn)
|
||||
marke = gemessen("Gruppen", marke)
|
||||
|
||||
profile = DirectoryProfile(
|
||||
username=_attr(entry, attribute) or username,
|
||||
@@ -322,6 +417,15 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
||||
email=_attr(entry, email_attribute),
|
||||
group_dns=group_dns,
|
||||
)
|
||||
# Eine Zeile je Anmeldung mit den Dauern der Stufen. Aus "dauert
|
||||
# manchmal laenger" wird damit eine Zahl -- und man sieht, ob es die
|
||||
# Verbindung ist, die Suche oder die Gruppen.
|
||||
logger.info(
|
||||
"Verzeichnisanmeldung %s: %s, gesamt %dms",
|
||||
username,
|
||||
", ".join(zeiten),
|
||||
int((time.monotonic() - begonnen) * 1000),
|
||||
)
|
||||
return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
|
||||
finally:
|
||||
try:
|
||||
|
||||
@@ -34,7 +34,7 @@ import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
import tesm_licensing as lic
|
||||
|
||||
@@ -54,6 +54,21 @@ SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
|
||||
EXPIRY_HINT_DAYS = 90
|
||||
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_PENDING = "pending"
|
||||
STATUS_ACTIVE = "active"
|
||||
@@ -121,6 +136,8 @@ class LicenseState:
|
||||
last_heartbeat_at: str = ""
|
||||
heartbeat_error: str = ""
|
||||
pending_action: str = ""
|
||||
#: Vor Ort abgeschaltet -- unabhaengig davon, was die Lizenz erlaubt.
|
||||
modules_off: frozenset[str] = frozenset()
|
||||
|
||||
@property
|
||||
def role(self) -> str:
|
||||
@@ -128,8 +145,14 @@ class LicenseState:
|
||||
|
||||
@property
|
||||
def modules(self) -> frozenset[str]:
|
||||
"""Was die Lizenz freischaltet -- ohne Ruecksicht auf den Schalter."""
|
||||
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
|
||||
def customer_name(self) -> str:
|
||||
return str((self.status.customer if self.status else {}).get("name") or "")
|
||||
@@ -179,6 +202,7 @@ class LicenseManager:
|
||||
"""Liest Lizenzdatei und Status neu ein."""
|
||||
with self._lock:
|
||||
state = LicenseState()
|
||||
state.modules_off = self.abgeschaltete_module(conn)
|
||||
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 "")
|
||||
pending = self._pending(conn)
|
||||
@@ -239,9 +263,51 @@ class LicenseManager:
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
"""Lizenzpflichtige Einzelfunktion.
|
||||
|
||||
|
||||
@@ -328,6 +328,83 @@ def ensure_presets(
|
||||
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(
|
||||
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
|
||||
) -> int:
|
||||
@@ -345,20 +422,7 @@ def funktionsgruppe_fuer_administratoren(
|
||||
|
||||
Gibt die Nummer der Gruppe zurueck.
|
||||
"""
|
||||
zeile = Database.one(
|
||||
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"])
|
||||
|
||||
gruppe = funktionsgruppe_anlegen(conn, tree, preset)
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
|
||||
[
|
||||
|
||||
@@ -58,6 +58,30 @@ class SettingSpec:
|
||||
return str(raw)
|
||||
|
||||
def serialize(self, value: Any) -> str:
|
||||
"""Ein Wert als Text fuer die Datenbank.
|
||||
|
||||
Der Wert kann als Python-Wert kommen (aus einem Formular, aus dem
|
||||
Code) -- oder als die Zeichenkette, die schon in der Datenbank stand.
|
||||
Genau so liest eine **Sicherung** ihn wieder ein, und genau dort lag
|
||||
ein stiller Fehler: ``serialize("0")`` ergab ``"1"``, weil jede nicht
|
||||
leere Zeichenkette wahr ist. Aus jedem abgeschalteten Schalter wurde
|
||||
beim Einspielen ein eingeschalteter -- auch aus
|
||||
``ldap_allow_insecure``. Ein JSON-Feld wurde ein zweites Mal kodiert,
|
||||
und aus der Navigationsreihenfolge damit eine Zeichenkette.
|
||||
|
||||
Deshalb: eine Zeichenkette erst lesen. Was hier herauskommt, muss
|
||||
durch ``serialize`` unveraendert hindurchgehen -- sonst ueberlebt kein
|
||||
Wert eine Sicherung.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
if self.kind == "bool":
|
||||
value = self.parse(value)
|
||||
elif self.kind == "json":
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except ValueError:
|
||||
# Kein JSON-Text, also ein Wert: dann wird er kodiert.
|
||||
pass
|
||||
if self.kind == "bool":
|
||||
return "1" if value else "0"
|
||||
if self.kind == "json":
|
||||
|
||||
@@ -1208,6 +1208,12 @@
|
||||
--tile-accent: var(--text-faint);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* Spalte, damit die Fusszeile unten haengen kann (siehe .tile__actions).
|
||||
Die Kacheln eines Rasters sind gleich hoch, ihr Inhalt aber nicht: mal
|
||||
fehlt der Switch, mal der Standort. Ohne diesen Halt stuenden die Knoepfe
|
||||
in jeder Kachel auf einer anderen Hoehe -- und das Auge sucht sie. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--sp-4);
|
||||
padding-left: calc(var(--sp-4) + 3px);
|
||||
border: 1px solid var(--border-subtle);
|
||||
@@ -1269,6 +1275,24 @@
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Fusszeile der Kachel: links der Bildschirm, rechts der Neustart.
|
||||
``margin-top: auto`` schiebt sie an den unteren Rand -- unabhaengig
|
||||
davon, wie viel darueber steht. Die beiden Eingriffe stehen damit in
|
||||
jeder Kachel an derselben Stelle, und der eine liegt nicht dort, wo eben
|
||||
noch der andere war. */
|
||||
.tile__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin-top: auto;
|
||||
padding-top: var(--sp-3);
|
||||
}
|
||||
.tile__actions > :last-child { margin-left: auto; }
|
||||
/* Steht nur ein Knopf da, bleibt er links -- sonst spraenge der Neustart bei
|
||||
einem Geraet ohne Bildschirmzugang an die Stelle, an der nebenan VNC
|
||||
steht. Das Fuellstueck haelt den Platz. */
|
||||
.tile__spacer { flex: 1 1 auto; }
|
||||
|
||||
/* Marke und Karte stehen uebereinander im normalen Fluss. Vorher lag die
|
||||
Marke absolut positioniert hinter der Karte -- auf einem flachen Fenster
|
||||
schnitt die Karte mitten durch den Schriftzug und die Funktionszeile. Ein
|
||||
|
||||
Reference in new issue
Block a user