Compare commits

...
8 Commits
26 changed files with 1498 additions and 131 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm"
version = "2.8.1"
version = "2.8.9"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11"
dependencies = [
@@ -74,6 +74,12 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
maintenance_active=_maintenance_active(),
darf=current_user.has,
),
# Aus dem letzten Durchlauf, nicht frisch gefragt: die
# Uebersicht zeigt viele Kacheln und laedt sich selbst
# nach. Ein Verbindungsversuch je Kachel und Aufbau waere
# genau die Art Hintergrundlast, die niemand bestellt hat.
"vnc": bool(row["vnc_open"])
and current_user.has("device_vnc.execute"),
}
)
tiles.append(entry)
+74 -2
View File
@@ -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)
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",
},
)
+86 -2
View File
@@ -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()
+24 -1
View File
@@ -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
View File
@@ -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",
+30 -72
View File
@@ -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",
+134
View File
@@ -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,136 @@ 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;
"""
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 +544,6 @@ 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),
)
+1 -1
View File
@@ -542,7 +542,7 @@ 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
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
+200 -16
View File
@@ -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
+29 -2
View File
@@ -507,6 +507,14 @@ class SweepReport:
duration_ms: int = 0
def merke_vnc(conn: sqlite3.Connection, device_id: int, offen: bool) -> None:
"""Haelt fest, ob dort gerade ein VNC-Server antwortet."""
conn.execute(
"UPDATE device_status SET vnc_open=?, vnc_checked_at=? WHERE device_id=?",
(1 if offen else 0, _now(), device_id),
)
def run_sweep(
*,
database: Any,
@@ -514,13 +522,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 +547,26 @@ def run_sweep(
report.duration_ms = int((time.monotonic() - started) * 1000)
return report
def pruefen(device: dict[str, Any]) -> tuple[dict[str, Any], PingResult, bool | None]:
ergebnis = ping(str(device["ip"]))
if not vnc_port or not ergebnis.ok:
# Kein Ergebnis heisst "nicht gefragt" -- und laesst den zuletzt
# bekannten Stand stehen. Ein Geraet, das gerade neu startet, soll
# seinen Knopf nicht verlieren.
return device, ergebnis, None
return device, ergebnis, vnc_dienst.erreichbar(vnc_dienst.ziel(device), vnc_port)
with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool:
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)
if result.ok:
report.online += 1
else:
+123 -9
View File
@@ -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)
+108
View File
@@ -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_"):
+133
View File
@@ -0,0 +1,133 @@
"""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
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}"
def ziel(device: Mapping[str, Any]) -> str:
"""Der Name des Clients, sonst seine IP-Adresse.
Der Name ist die bessere Wahl, wo er aufloest: er ueberlebt einen
Adresswechsel. Taugt er nicht als Hostname -- weil er ein Leerzeichen
enthaelt oder ein Umlaut darin steht --, waere ein Link darauf sicher
falsch; dann ist die Adresse richtig.
"""
name = str(device.get("name") or "").strip()
if name and HOSTNAME_RE.match(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)
+21
View File
@@ -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(
@@ -99,6 +99,12 @@
</button>
</form>
{% endif %}
{% if tile.vnc %}
<a class="btn btn--sm" title="Verbindungsdatei für den Bildschirm"
href="{{ url_for('devices.vnc_connection', device_id=tile.id) }}">
{{ icon('monitor', size=14) }} VNC
</a>
{% endif %}
{% if current_user.has('devices.write') and tile.state == 'disabled' %}
<form method="post" action="{{ url_for('devices.toggle_active', device_id=tile.id) }}"
data-no-guard>
@@ -16,6 +16,12 @@
{{ icon('terminal', size=15) }} Konsole
</a>
{% endif %}
{% if vnc and vnc.erreichbar %}
<a class="btn btn--sm" href="{{ url_for('devices.vnc_connection', device_id=device.id) }}"
title="Verbindungsdatei für {{ vnc.ziel }}:{{ vnc.port }} – öffnet den VNC-Viewer">
{{ icon('monitor', size=15) }} VNC
</a>
{% endif %}
{% if restart_methods %}
{% for method in restart_methods %}
<form method="post" style="display:inline"
@@ -225,6 +231,24 @@
<dt>Kategorie</dt><dd>{{ device.category or '&ndash;' | safe }}</dd>
<dt>Standort</dt><dd>{{ device.location or '&ndash;' | 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">
<a href="{{ url_for('devices.vnc_connection', device_id=device.id) }}">
Verbindungsdatei herunterladen</a> – enthält nur Ziel und Namen.
Der Link daneben braucht einen Viewer, der <code>vnc://</code> übernimmt.
</div>
{% else %}
<span class="faint">
kein VNC-Server auf {{ vnc.ziel }}:{{ vnc.port }}
{%- if device.state != 'online' %} (Client antwortet gerade nicht){% endif %}
</span>
{% endif %}
</dd>
{% endif %}
<dt>Automatischer Neustart</dt><dd>{{ device.auto_restart | yes_no }}</dd>
<dt>Angelegt</dt><dd>{{ device.created_at | dt }}</dd>
<dt>Geändert</dt>
+10 -1
View File
@@ -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>
+2 -1
View File
@@ -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), "
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-core"
version = "2.8.0"
version = "2.8.3"
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":