Compare commits

...
4 Commits
14 changed files with 717 additions and 106 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm" name = "tesm"
version = "2.8.3" version = "2.8.7"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+80 -1
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.extension import core, db, transaction
from tesm_core.licensing_client import LicenseError from tesm_core.licensing_client import LicenseError
import tesm_licensing as lic
bp = Blueprint("settings", __name__, url_prefix="/administration") bp = Blueprint("settings", __name__, url_prefix="/administration")
@@ -46,6 +48,28 @@ def _license_hint(feature: str) -> str:
return "" return ""
def _module_rows(conn: Any) -> list[dict[str, Any]]:
"""Die drei Module mit ihrem Zustand -- lizenziert, benutzt.
Zwei verschiedene Fragen, die die Seite auseinanderhalten muss: was die
Lizenz erlaubt (dagegen hilft kein Schalter) und was dieses Haus benutzen
will (dafuer ist der Schalter da).
"""
manager = core().license
if manager is None:
return []
state = manager.reload(conn)
return [
{
"key": name,
"label": lic.MODULE_LABELS.get(name, name),
"licensed": name in state.modules,
"enabled": name not in state.modules_off,
}
for name in lic.ALL_MODULES
]
def _current_timezone() -> str: def _current_timezone() -> str:
sysops = core().sysops sysops = core().sysops
if sysops is None: if sysops is None:
@@ -94,11 +118,16 @@ def system():
hostname=(sysops.read_command(["hostname"]).stdout.strip() if sysops else ""), hostname=(sysops.read_command(["hostname"]).stdout.strip() if sysops else ""),
timezone=_current_timezone(), timezone=_current_timezone(),
timezones=_timezone_choices(), timezones=_timezone_choices(),
nav_items=extension.nav.items, # Nicht ``nav_items``: dieser Name gehoert dem Seitenrahmen. Wer ihn hier
# belegt, ueberschreibt die gefilterte Navigation aus dem Kontext --
# und die Seitenleiste zeigte dann jeden Punkt, auch den, den dieser
# Benutzer nicht darf und den die Lizenz nicht freischaltet.
nav_baum=extension.nav.items,
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(), nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
nav_child_order=extension.settings.get(conn, "nav_child_order") or {}, nav_child_order=extension.settings.get(conn, "nav_child_order") or {},
license_hint=_license_hint("monitor_interval"), license_hint=_license_hint("monitor_interval"),
sysops_available=bool(sysops and sysops.available), sysops_available=bool(sysops and sysops.available),
module_rows=_module_rows(conn),
) )
@@ -167,6 +196,56 @@ def system_save():
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
@bp.post("/system/modules")
@permission_required("settings_system.write")
def modules_save():
"""Module ein- und ausschalten.
Abgeschaltet heisst wirklich abgeschaltet: der Punkt verschwindet aus der
Navigation, und die Routen antworten mit 404 -- dieselbe Sperre, die auch
eine fehlende Lizenz zieht. Daten bleiben unangetastet; wer das Modul
wieder einschaltet, findet alles vor wie zuvor.
"""
extension = core()
manager = extension.license
if manager is None:
flash("Diese Installation nutzt keine Lizenzierung.", "info")
return redirect(url_for("settings.system"))
aus = [name for name in lic.ALL_MODULES if request.form.get(f"module_{name}") != "1"]
with transaction() as conn:
eingeschaltet, abgeschaltet = manager.module_abschalten(
conn, aus, actor=current_user.username
)
if eingeschaltet or abgeschaltet:
teile = []
if abgeschaltet:
teile.append(
"abgeschaltet: "
+ ", ".join(lic.MODULE_LABELS.get(n, n) for n in sorted(abgeschaltet))
)
if eingeschaltet:
teile.append(
"eingeschaltet: "
+ ", ".join(lic.MODULE_LABELS.get(n, n) for n in sorted(eingeschaltet))
)
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="settings.modules_changed",
detail="; ".join(teile),
ip=client_ip(),
severity="notice",
)
if eingeschaltet or abgeschaltet:
flash("Module gespeichert.", "success")
else:
flash("Keine Änderungen.", "info")
return redirect(url_for("settings.system"))
@bp.post("/system/hostname") @bp.post("/system/hostname")
@permission_required("settings_system.execute") @permission_required("settings_system.execute")
def set_hostname(): def set_hostname():
+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] 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: 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] 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() extension = core()
conn = db() conn = db()
_send(ws, "info", "Sitzung wird geöffnet …")
# -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein # -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein
# WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich. # 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" / f"{stamp}-{_transcript_name(current_user.username)}-{_transcript_name(target.label)}.log"
) )
_send(ws, "info", f"Ziel: {target.label} ({target.host}:{target.port})")
try: try:
username, password = inventory.credential_secret( username, password = inventory.credential_secret(
conn, extension.keystore, target.credential_id 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: except inventory.ValidationError as exc:
_send(ws, "error", str(exc)) _send(ws, "error", str(exc))
return return
_send(ws, "info", f"Anmeldung als {username} …")
with extension.database.transaction(conn): with extension.database.transaction(conn):
audit.log( 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 ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
) -> int: ) -> int:
commands = 0 commands = 0
_send(ws, "info", "Warte auf die Eingabeaufforderung des Switches …")
with switchcli.SwitchCli( with switchcli.SwitchCli(
conn, host=target.host, port=target.port, username=username, password=password conn, host=target.host, port=target.port, username=username, password=password
) as cli: ) 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 ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
) -> int: ) -> int:
commands = 0 commands = 0
_send(ws, "info", "Verbindung wird aufgebaut …")
with shellcli.ShellSession( with shellcli.ShellSession(
conn, host=target.host, port=target.port, username=username, password=password conn, host=target.host, port=target.port, username=username, password=password
) as session: ) as session:
+154 -1
View File
@@ -69,13 +69,33 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
) )
user_id = int(cursor.lastrowid or 0) user_id = int(cursor.lastrowid or 0)
action = "angelegt" action = "angelegt"
# Der Vollzugriff gehoert dazu. Seit 2.8.0 gibt das
# Administratorkennzeichen den Verwaltungsbereich und sonst
# nichts; Funktionsrechte kommen ueber Gruppen. Ein Konto, das
# jemand an der Kommandozeile anlegt, ist aber der Notausgang --
# es entsteht, wenn sonst niemand mehr hineinkommt, und dann
# nuetzt eine Verwaltung ohne Geraete, Freigaben und Protokolle
# wenig. Wer das nicht will, nimmt die Gruppe in einem Klick
# wieder weg.
from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
gruppe = rbac.funktionsgruppe_anlegen(
conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF
)
conn.execute(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
(user_id, gruppe),
)
audit.log( audit.log(
conn, conn,
actor="cli", actor="cli",
actor_id=user_id, actor_id=user_id,
action="user.admin_created", action="user.admin_created",
target=username, target=username,
detail=f"über die Kommandozeile {action}", detail=(
f"über die Kommandozeile {action}, Gruppe "
f"{FUNKTIONEN_VOLLZUGRIFF.name} zugewiesen"
),
severity="warning", severity="warning",
) )
print(f"Administrator {username!r} {action}.") print(f"Administrator {username!r} {action}.")
@@ -216,6 +236,123 @@ def cmd_web_setup(args: argparse.Namespace) -> int:
return run_web_setup(app, extension, args) 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: def cmd_convert_legacy(args: argparse.Namespace) -> int:
"""Uebersetzt einen Export des alten TESM in das neue Format. """Uebersetzt einen Export des alten TESM in das neue Format.
@@ -418,6 +555,22 @@ def build_parser() -> argparse.ArgumentParser:
check = sub.add_parser("check-now", help="Einen Überwachungsdurchlauf sofort ausführen") check = sub.add_parser("check-now", help="Einen Überwachungsdurchlauf sofort ausführen")
check.set_defaults(func=cmd_check_now) 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( legacy = sub.add_parser(
"convert-legacy", "convert-legacy",
help="Export des alten TESM in das neue Format übersetzen", help="Export des alten TESM in das neue Format übersetzen",
+9 -72
View File
@@ -388,6 +388,15 @@ FUNKTIONEN_VOLLZUGRIFF = RolePreset(
) )
#: Die Gruppen, die eine frische Installation mitbringt -- drei, nicht sieben.
#:
#: Vorher standen hier zusaetzlich Betrieb, Support, Netzwerk und Revision.
#: Gut gemeint, aber sie beantworten eine Frage, die jeder Betrieb anders
#: beantwortet: welche Rolle es gibt, weiss der Kunde, nicht die Anwendung.
#: Was bleibt, sind die drei, ohne die man nicht anfangen kann -- eine
#: Standardgruppe fuer jeden neuen Nutzer, eine fuer die Verwaltung und eine,
#: die alle Funktionen buendelt. Weitere legt man in der Matrix an, und die
#: Matrix zeigt seit 2.8.0 je Funktion eine Zeile.
ROLE_PRESETS: tuple[RolePreset, ...] = ( ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset( RolePreset(
key="viewer", key="viewer",
@@ -400,78 +409,6 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
), ),
is_default=True, is_default=True,
), ),
RolePreset(
key="operations",
name="Betrieb",
description="Geräte und Switche pflegen, Neustarts auslösen, Wartung fahren.",
grants=(
("devices", "*"),
("device_restart_poe", "*"),
("device_restart_ssh", "*"),
("device_restart_rpc", "*"),
("device_console", "*"),
("switches", (A.READ, A.CREATE, A.WRITE)),
("switch_console", "*"),
("hostkeys", "*"),
("credentials", (A.READ,)),
("servicing_update", "*"),
("servicing_reboot", "*"),
("servicing_tool", "*"),
("logs_live", (A.READ,)),
("logs_history", (A.READ,)),
("trash", (A.READ, A.WRITE)),
),
),
RolePreset(
key="support",
name="Support",
description="Erste Hilfe am Telefon: Status sehen, PoE-Neustart auslösen.",
grants=(
("devices", (A.READ,)),
# Nur der PoE-Weg: der wirkt auch bei einem haengenden Geraet und
# unterbricht nichts Laufendes. SSH und RPC fahren ein arbeitendes
# System herunter -- das gehoert nicht in die erste Hilfe.
("device_restart_poe", "*"),
("switches", (A.READ,)),
("logs_live", (A.READ,)),
),
),
RolePreset(
key="network",
name="Netzwerk",
description="DHCP und Host-Netzwerkeinstellungen.",
grants=(
("dhcp_subnets", "*"),
("dhcp_options", "*"),
("dhcp_reservations", "*"),
("dhcp_config", "*"),
("dhcp_service", "*"),
("settings_network", "*"),
("logs_dhcp", (A.READ,)),
("devices", (A.READ,)),
("switches", (A.READ,)),
),
),
RolePreset(
key="auditor",
name="Revision",
description="Nur lesen – inklusive Änderungsprotokoll und Diagnose.",
grants=(
("devices", (A.READ,)),
("switches", (A.READ,)),
("credentials", (A.READ,)),
("dhcp_subnets", (A.READ,)),
("dhcp_options", (A.READ,)),
("dhcp_reservations", (A.READ,)),
("users", (A.READ,)),
("groups", (A.READ,)),
("logs_live", (A.READ,)),
("logs_history", (A.READ, A.EXPORT)),
("logs_audit", (A.READ, A.EXPORT)),
("diagnostics", (A.READ,)),
("trash", (A.READ,)),
),
),
RolePreset( RolePreset(
key="administration", key="administration",
name="Administration", name="Administration",
+120
View File
@@ -23,6 +23,8 @@ Frische Zeitlinie ab Version 1 -- kein Nachbau der 35 gewachsenen
from __future__ import annotations from __future__ import annotations
from typing import Any
from tesm_core.migrations import Migration from tesm_core.migrations import Migration
V1 = """ V1 = """
@@ -393,6 +395,123 @@ def uebergang_funktionsgruppe(conn) -> None: # type: ignore[no-untyped-def]
rbac.funktionsgruppe_fuer_administratoren(conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF) rbac.funktionsgruppe_fuer_administratoren(conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF)
#: Die vier Vorlagen, die es bis 2.8.6 gab und ab 2.8.7 nicht mehr.
#:
#: Stehen hier als eingefrorene Kopie, weil eine Migration ihre eigenen Daten
#: mitbringen muss: in ``permissions.py`` sind sie weg, und trotzdem muss
#: dieser Schritt noch erkennen koennen, ob eine vorgefundene Gruppe wirklich
#: unveraendert die ausgelieferte ist.
ABGELEGTE_VORLAGEN: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...] = (
(
"operations",
"Betrieb",
(
("devices", "*"),
("device_restart_poe", "*"),
("device_restart_ssh", "*"),
("device_restart_rpc", "*"),
("device_console", "*"),
("switches", ("read", "create", "write")),
("switch_console", "*"),
("hostkeys", "*"),
("credentials", ("read",)),
("servicing_update", "*"),
("servicing_reboot", "*"),
("servicing_tool", "*"),
("logs_live", ("read",)),
("logs_history", ("read",)),
("trash", ("read", "write")),
),
),
(
"support",
"Support",
(
("devices", ("read",)),
("device_restart_poe", "*"),
("switches", ("read",)),
("logs_live", ("read",)),
),
),
(
"network",
"Netzwerk",
(
("dhcp_subnets", "*"),
("dhcp_options", "*"),
("dhcp_reservations", "*"),
("dhcp_config", "*"),
("dhcp_service", "*"),
("settings_network", "*"),
("logs_dhcp", ("read",)),
("devices", ("read",)),
("switches", ("read",)),
),
),
(
"auditor",
"Revision",
(
("devices", ("read",)),
("switches", ("read",)),
("credentials", ("read",)),
("dhcp_subnets", ("read",)),
("dhcp_options", ("read",)),
("dhcp_reservations", ("read",)),
("users", ("read",)),
("groups", ("read",)),
("logs_live", ("read",)),
("logs_history", ("read", "export")),
("logs_audit", ("read", "export")),
("diagnostics", ("read",)),
("trash", ("read",)),
),
),
)
def eingefrorene_vorlagen(
beschreibung: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...],
) -> list[Any]:
"""Baut aus der Kopie oben wieder ``RolePreset``-Objekte.
Die Aktionen stehen dort als Text und nicht als ``Action``, damit diese
Migration auch dann noch lesbar bleibt, wenn das Enum sich weiterentwickelt.
"""
from tesm_core.rbac.model import Action, RolePreset
vorlagen = []
for key, name, grants in beschreibung:
gewandelt = tuple(
(ressource, aktionen if aktionen == "*" else tuple(Action(a) for a in aktionen))
for ressource, aktionen in grants
)
vorlagen.append(RolePreset(key=key, name=name, description="", grants=gewandelt))
return vorlagen
def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
"""Aus sieben Standardgruppen werden drei -- ohne jemandem etwas wegzunehmen.
Was eine Anwendung an Rollen mitbringt, ist eine Vermutung darueber, wie
ein Betrieb organisiert ist. Sieben davon sind sechs zu viele: wer sie
nicht braucht, muss sie trotzdem jedes Mal ueberlesen, und in der
Rechtematrix stand fuer jede eine Spalte. Es bleiben "Benutzer",
"Administration" und "Funktionen (Vollzugriff)".
Weggeraeumt wird nur, was unberuehrt ist -- keine Mitglieder, Rechte
unveraendert. Alles andere bleibt stehen, wo es steht. Und auch das
Weggeraeumte liegt nur im Papierkorb.
"""
from tesm_core.rbac import service as rbac
from .permissions import PERMISSIONS
rbac.unberuehrte_vorlagen_entfernen(
conn, PERMISSIONS, eingefrorene_vorlagen(ABGELEGTE_VORLAGEN)
)
TESM_MIGRATIONS: tuple[Migration, ...] = ( TESM_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1), Migration(version=1, name="baseline", sql=V1),
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options), Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
@@ -412,4 +531,5 @@ TESM_MIGRATIONS: tuple[Migration, ...] = (
), ),
Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen), Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen),
Migration(version=8, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe), Migration(version=8, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe),
Migration(version=9, name="standardgruppen-aufraeumen", fn=uebergang_standardgruppen),
) )
+28 -8
View File
@@ -30,9 +30,26 @@ from .ssh import SshError, open_transport
logger = logging.getLogger(__name__) 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 #: Erkennt Operator- (``>``) und privilegierten Modus (``#``) sowie den
#: Konfigurationsmodus (``switch(config)#``). #: 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) PAGER_RE = re.compile(rb"(--\s?MORE\s?--|Press any key to continue)", re.IGNORECASE)
ERROR_RE = re.compile( ERROR_RE = re.compile(
r"(invalid input|unknown command|incomplete|ambiguous|not supported|% *error)", re.IGNORECASE r"(invalid input|unknown command|incomplete|ambiguous|not supported|% *error)", re.IGNORECASE
@@ -175,27 +192,30 @@ class SwitchCli:
""" """
assert self._channel is not None assert self._channel is not None
deadline = time.monotonic() + timeout 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 prompt_gesehen = False
while time.monotonic() < deadline: while time.monotonic() < deadline:
if self._channel.recv_ready(): if self._channel.recv_ready():
chunk = self._channel.recv(READ_CHUNK) chunk = self._channel.recv(READ_CHUNK)
if not chunk: if not chunk:
break break
collected += chunk sichtbar = ANSI_RE.sub(b"", sichtbar + chunk)
if PAGER_RE.search(collected[-200:]): if PAGER_RE.search(sichtbar[-300:]):
self._channel.send(" ") self._channel.send(" ")
collected = PAGER_RE.sub(b"", collected) sichtbar = PAGER_RE.sub(b"", sichtbar)
continue continue
if PROMPT_RE.search(collected[-200:]): if PROMPT_RE.search(sichtbar[-300:]):
prompt_gesehen = True prompt_gesehen = True
break break
# Der Login-Banner mancher Geraete verlangt eine Bestaetigung. # 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") self._channel.send("\r\n")
else: else:
time.sleep(0.05) time.sleep(0.05)
text = collected.decode("utf-8", errors="replace") text = sichtbar.decode("utf-8", errors="replace")
self.transcript.append(text) self.transcript.append(text)
if not prompt_gesehen: if not prompt_gesehen:
letzte = " ".join(text.split())[-200:] letzte = " ".join(text.split())[-200:]
+10 -1
View File
@@ -60,9 +60,18 @@
<dd> <dd>
{% if state.modules %} {% if state.modules %}
{% for module in modules %} {% for module in modules %}
{% if module in state.modules %}{{ pill(module_labels[module], 'online') }} {% if module in state.modules and module in state.modules_off %}
{{ pill(module_labels[module] ~ ' (abgeschaltet)', 'warning') }}
{% elif module in state.modules %}{{ pill(module_labels[module], 'online') }}
{% else %}{{ pill(module_labels[module], '') }}{% endif %} {% else %}{{ pill(module_labels[module], '') }}{% endif %}
{% endfor %} {% endfor %}
{% if state.modules_off %}
<div class="fs-xs faint">
Abgeschaltet wurde in
<a href="{{ url_for('settings.system') }}">Verwaltung → System</a>;
die Lizenz ist davon unberührt.
</div>
{% endif %}
{% else %}<span class="faint">keine Zusatzmodule</span>{% endif %} {% else %}<span class="faint">keine Zusatzmodule</span>{% endif %}
</dd> </dd>
<dt>Lizenzserver</dt> <dt>Lizenzserver</dt>
@@ -51,6 +51,34 @@
</form> </form>
</section> </section>
{% if module_rows %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('puzzle', size=17) }} Module</div>
<div class="card__sub">
Die Lizenz sagt, was erlaubt ist – hier steht, was dieses Haus benutzt.
Ein abgeschaltetes Modul verschwindet aus der Navigation, und seine Seiten
antworten wie ohne Lizenz. Bestehende Daten bleiben unverändert stehen und
sind nach dem Wiedereinschalten wieder da.
</div>
</div>
</div>
<form method="post" action="{{ url_for('settings.modules_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{% for modul in module_rows %}
{{ checkbox('module_' ~ modul.key, modul.label, checked=modul.enabled,
hint_text=('In der Lizenz enthalten.' if modul.licensed
else 'Nicht lizenziert – bleibt aus, bis die Lizenz es
freischaltet. Der Haken gilt dann sofort.')) }}
{% endfor %}
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form>
</section>
{% endif %}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
@@ -189,7 +217,7 @@
</section> </section>
{% if current_user.is_admin %} {% if current_user.is_admin %}
{{ nav_order_card(nav_items, nav_order, nav_child_order, {{ nav_order_card(nav_baum, nav_order, nav_child_order,
url_for('admin.nav_order'), csrf_field_name, csrf_token()) }} url_for('admin.nav_order'), csrf_field_name, csrf_token()) }}
{% endif %} {% endif %}
</div> </div>
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" name = "tesm-core"
version = "2.8.1" version = "2.8.3"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -102,6 +102,14 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
), ),
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- # # -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung. # Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
SettingSpec(
"modules_disabled",
[],
"json",
"Vor Ort abgeschaltete Module",
hint="Die Lizenz sagt, was erlaubt ist; das hier sagt, was benutzt wird.",
section="system",
),
SettingSpec( SettingSpec(
"license_server_ca", "license_server_ca",
"", "",
@@ -26,6 +26,7 @@ import re
import socket import socket
import sqlite3 import sqlite3
import ssl import ssl
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Literal from typing import Any, Literal
@@ -162,19 +163,82 @@ def _tls_object(config: dict[str, Any]):
def _server(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 from ldap3 import Server
security = str(config["ldap_security"]) security = str(config["ldap_security"])
braucht_basis = not str(config.get("ldap_base_dn") or "").strip()
return Server( return Server(
str(config["ldap_host"]), str(config["ldap_host"]),
port=int(config["ldap_port"]), port=int(config["ldap_port"]),
use_ssl=security == "ldaps", use_ssl=security == "ldaps",
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None, 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"]), 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): def _connect(config: dict[str, Any], user: str, password: str):
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen.""" """Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
from ldap3 import Connection from ldap3 import Connection
@@ -192,6 +256,12 @@ def _connect(config: dict[str, Any], user: str, password: str):
auto_bind=False, auto_bind=False,
raise_exceptions=False, raise_exceptions=False,
receive_timeout=int(config["ldap_timeout"]), 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 security == "starttls":
if not connection.open(): if not connection.open():
@@ -253,8 +323,19 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
if not config["ldap_verify_cert"]: if not config["ldap_verify_cert"]:
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprüft") 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: 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: except (PermissionError, ConnectionError) as exc:
logger.error("Verzeichnisdienst: %s", exc) logger.error("Verzeichnisdienst: %s", exc)
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings)) 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), warnings=tuple(warnings),
) )
marke = gemessen("Dienstbindung", begonnen)
try: try:
base = _base_dn(service_conn, config) base = _base_dn(service_conn, config)
marke = gemessen("Basis-DN", marke)
if not base: if not base:
return DirectoryResult( return DirectoryResult(
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings) "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)), attributes=list(dict.fromkeys(wanted)),
size_limit=2, size_limit=2,
) )
marke = gemessen("Benutzersuche", marke)
entries = list(service_conn.entries) entries = list(service_conn.entries)
if len(entries) != 1: if len(entries) != 1:
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
return DirectoryResult("invalid", warnings=tuple(warnings)) return DirectoryResult("invalid", warnings=tuple(warnings))
entry = entries[0] 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 # Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden. # 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: try:
if not user_bound: if not user_bound:
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
return DirectoryResult("invalid", warnings=tuple(warnings)) return DirectoryResult("invalid", warnings=tuple(warnings))
finally: finally:
try: try:
@@ -313,6 +407,7 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
group_dns: tuple[str, ...] = () group_dns: tuple[str, ...] = ()
if config["ldap_group_sync"]: if config["ldap_group_sync"]:
group_dns = _resolve_groups(service_conn, base, dn) group_dns = _resolve_groups(service_conn, base, dn)
marke = gemessen("Gruppen", marke)
profile = DirectoryProfile( profile = DirectoryProfile(
username=_attr(entry, attribute) or username, 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), email=_attr(entry, email_attribute),
group_dns=group_dns, 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)) return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
finally: finally:
try: try:
@@ -34,7 +34,7 @@ import urllib.request
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable, Iterable
import tesm_licensing as lic import tesm_licensing as lic
@@ -54,6 +54,21 @@ SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
EXPIRY_HINT_DAYS = 90 EXPIRY_HINT_DAYS = 90
SETTING_EXPORT_GRACE = "license_export_grace" SETTING_EXPORT_GRACE = "license_export_grace"
#: Module, die der Betreiber vor Ort abgeschaltet hat (Liste von Namen).
#:
#: Die Lizenz sagt, *was erlaubt ist*; sie sagt nicht, was ein Haus auch
#: benutzen will. Wer kein DHCP ueber TESM fahren will, weil ein anderer Server
#: das tut, soll den Punkt nicht wegdruecken muessen -- und vor allem soll
#: niemand versehentlich eine fremde Kea-Konfiguration ueberschreiben koennen.
#: Abschalten ist deshalb mehr als Aufraeumen der Navigation: die Sperre sitzt
#: an derselben Stelle wie die Lizenzpruefung, also vor jeder Route.
#: Bewusst **ohne** das Praefix ``license_``: Sicherungen lassen alles mit
#: diesem Praefix draussen, weil Lizenz- und Aktivierungszustand zur Maschine
#: gehoeren und nicht in ein Backup. Der Schalter ist aber keine Lizenzangabe,
#: sondern eine Entscheidung des Hauses -- die soll eine Wiederherstellung
#: mitbringen.
SETTING_MODULES_OFF = "modules_disabled"
STATUS_INACTIVE = "inactive" STATUS_INACTIVE = "inactive"
STATUS_PENDING = "pending" STATUS_PENDING = "pending"
STATUS_ACTIVE = "active" STATUS_ACTIVE = "active"
@@ -121,6 +136,8 @@ class LicenseState:
last_heartbeat_at: str = "" last_heartbeat_at: str = ""
heartbeat_error: str = "" heartbeat_error: str = ""
pending_action: str = "" pending_action: str = ""
#: Vor Ort abgeschaltet -- unabhaengig davon, was die Lizenz erlaubt.
modules_off: frozenset[str] = frozenset()
@property @property
def role(self) -> str: def role(self) -> str:
@@ -128,8 +145,14 @@ class LicenseState:
@property @property
def modules(self) -> frozenset[str]: def modules(self) -> frozenset[str]:
"""Was die Lizenz freischaltet -- ohne Ruecksicht auf den Schalter."""
return self.status.modules if self.status else frozenset() return self.status.modules if self.status else frozenset()
@property
def modules_in_use(self) -> frozenset[str]:
"""Was tatsaechlich laeuft: lizenziert **und** nicht abgeschaltet."""
return self.modules - self.modules_off
@property @property
def customer_name(self) -> str: def customer_name(self) -> str:
return str((self.status.customer if self.status else {}).get("name") or "") return str((self.status.customer if self.status else {}).get("name") or "")
@@ -179,6 +202,7 @@ class LicenseManager:
"""Liest Lizenzdatei und Status neu ein.""" """Liest Lizenzdatei und Status neu ein."""
with self._lock: with self._lock:
state = LicenseState() state = LicenseState()
state.modules_off = self.abgeschaltete_module(conn)
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "") state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "") state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
pending = self._pending(conn) pending = self._pending(conn)
@@ -239,9 +263,51 @@ class LicenseManager:
) )
def module_active(self, name: str) -> bool: def module_active(self, name: str) -> bool:
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) freigeschaltet?""" """Ist ein Vollmodul (dhcp/fileshare/maintenance) in Betrieb?
Zwei Bedingungen, und beide muessen gelten: die Lizenz muss es
freischalten, und der Betreiber darf es nicht abgeschaltet haben. Diese
eine Funktion fragen die Navigation, jede Route und die Hintergrund-
aufgaben -- darum sitzt der Schalter hier und nicht in einer Vorlage.
"""
return (
self.active()
and name in self._state.modules
and name not in self._state.modules_off
)
def module_licensed(self, name: str) -> bool:
"""Schaltet die Lizenz das Modul frei? (Ohne den Schalter vor Ort.)"""
return self.active() and name in self._state.modules return self.active() and name in self._state.modules
def abgeschaltete_module(self, conn: sqlite3.Connection) -> frozenset[str]:
"""Liest die Liste aus den Einstellungen."""
gespeichert = self.settings.get(conn, SETTING_MODULES_OFF) or []
if isinstance(gespeichert, str): # Altbestand: ein einzelner Name
gespeichert = [gespeichert]
return frozenset(str(name) for name in gespeichert)
def module_abschalten(
self, conn: sqlite3.Connection, namen: Iterable[str], *, actor: str = ""
) -> tuple[frozenset[str], frozenset[str]]:
"""Setzt die Liste der abgeschalteten Module neu.
Gibt ``(eingeschaltet, abgeschaltet)`` zurueck -- also nur das, was sich
wirklich geaendert hat; der Aufrufer schreibt daraus seinen
Protokolleintrag.
Der Zustand wird hier nicht neu eingelesen, sondern nur an dieser einen
Stelle nachgezogen: ein voller Neuaufbau wuerde die Lizenzdatei erneut
pruefen, obwohl sich an ihr nichts geaendert hat -- und der Schalter ist
das Einzige, was jetzt anders ist.
"""
vorher = self.abgeschaltete_module(conn)
nachher = frozenset(str(name) for name in namen)
self.settings.set(conn, SETTING_MODULES_OFF, sorted(nachher), actor=actor)
with self._lock:
self._state.modules_off = nachher
return vorher - nachher, nachher - vorher
def feature_allowed(self, feature: str) -> bool: def feature_allowed(self, feature: str) -> bool:
"""Lizenzpflichtige Einzelfunktion. """Lizenzpflichtige Einzelfunktion.
@@ -328,6 +328,83 @@ def ensure_presets(
return created return created
def unberuehrte_vorlagen_entfernen(
conn: sqlite3.Connection, tree: PermissionTree, presets: Sequence[RolePreset]
) -> list[str]:
"""Legt Vorlagengruppen in den Papierkorb -- aber nur unberuehrte.
Wird gebraucht, wenn eine Vorlage aus der Auslieferung verschwindet. Auf
einer frischen Installation entsteht sie dann gar nicht erst; auf einer
bestehenden steht sie in der Datenbank, und eine Migration, die sie einfach
loescht, nimmt einem Kunden womoeglich eine Gruppe weg, mit der er
arbeitet.
Darum diese fuenf Bedingungen, und alle fuenf muessen zutreffen:
* Die Gruppe stammt **aus der Auslieferung**: ihr ``preset_key`` ist der der
Vorlage. Diese Kennung setzt nur :func:`ensure_presets`; eine selbst
angelegte Gruppe hat sie nicht, auch wenn sie zufaellig denselben Namen
traegt. Ueber den Namen wird bewusst *nicht* gesucht -- sonst raeumte
dieser Schritt jemandem seine eigene Gruppe "Betrieb" weg.
* Sie heisst noch so, wie sie ausgeliefert wurde. Ein anderer Name heisst:
jemand hat sie uebernommen.
* Die Gruppe hat **keine Mitglieder**. Wer Mitglieder hat, wird benutzt.
* Ihre Rechte sind **unveraendert** die der Vorlage. Jede Abweichung heisst:
jemand hat sie angepasst, also ist sie seine, nicht unsere.
* Sie ist keine Systemgruppe.
Geloescht wird weich: die Gruppe liegt im Papierkorb und kommt mit einem
Klick zurueck. Gibt die Namen der einsortierten Gruppen zurueck.
"""
entfernt: list[str] = []
gruppen = list_groups(conn)
for preset in presets:
gruppe = next(
(
g
for g in gruppen
if g.preset_key
and g.preset_key == preset.key
and g.name.lower() == preset.name.lower()
),
None,
)
if gruppe is None or gruppe.is_system or gruppe.member_count:
continue
if set(gruppe.permissions) != set(tree.expand_preset(preset)):
continue
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), gruppe.id))
entfernt.append(gruppe.name)
return entfernt
def funktionsgruppe_anlegen(
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
) -> int:
"""Legt die Vollzugriffsgruppe an, falls sie fehlt, und gibt ihre Nummer.
Getrennt von :func:`funktionsgruppe_fuer_administratoren`, weil das zwei
verschiedene Dinge sind: hier entsteht nur die Gruppe, dort bekommt sie
**jeder** Administrator. Wer ein einzelnes Konto aufnehmen will -- etwa
das, das an der Kommandozeile entsteht --, braucht das erste ohne das
zweite; sonst faengt sich ein Administrator, der die Gruppe bewusst
abgelegt hat, sie beim naechsten Aufruf wieder ein.
"""
zeile = Database.one(
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,)
)
if zeile is not None:
return int(zeile["id"])
cursor = conn.execute(
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
"created_at, updated_at) VALUES (?,?,0,0,?,?,?)",
(preset.name, preset.description, preset.key, _now(), _now()),
)
gruppe = int(cursor.lastrowid or 0)
_write_permissions(conn, gruppe, tree.expand_preset(preset))
return gruppe
def funktionsgruppe_fuer_administratoren( def funktionsgruppe_fuer_administratoren(
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
) -> int: ) -> int:
@@ -345,20 +422,7 @@ def funktionsgruppe_fuer_administratoren(
Gibt die Nummer der Gruppe zurueck. Gibt die Nummer der Gruppe zurueck.
""" """
zeile = Database.one( gruppe = funktionsgruppe_anlegen(conn, tree, preset)
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,)
)
if zeile is None:
cursor = conn.execute(
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
"created_at, updated_at) VALUES (?,?,0,0,?,?,?)",
(preset.name, preset.description, preset.key, _now(), _now()),
)
gruppe = int(cursor.lastrowid or 0)
_write_permissions(conn, gruppe, tree.expand_preset(preset))
else:
gruppe = int(zeile["id"])
conn.executemany( conn.executemany(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)", "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
[ [