Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ad2f3f9a5 | ||
|
|
9d5eac1db2 | ||
|
|
2fd78ac26b | ||
|
|
86c09d8a0f | ||
|
|
70f6b005a9 | ||
|
|
57eb7764cc |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm"
|
name = "tesm"
|
||||||
version = "2.8.2"
|
version = "2.8.8"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+162
-1
@@ -69,13 +69,41 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
|
|||||||
)
|
)
|
||||||
user_id = int(cursor.lastrowid or 0)
|
user_id = int(cursor.lastrowid or 0)
|
||||||
action = "angelegt"
|
action = "angelegt"
|
||||||
|
# Der Vollzugriff gehoert dazu. Seit 2.8.0 gibt das
|
||||||
|
# Administratorkennzeichen den Verwaltungsbereich und sonst
|
||||||
|
# nichts; Funktionsrechte kommen ueber Gruppen. Ein Konto, das
|
||||||
|
# jemand an der Kommandozeile anlegt, ist aber der Notausgang --
|
||||||
|
# es entsteht, wenn sonst niemand mehr hineinkommt, und dann
|
||||||
|
# nuetzt eine Verwaltung ohne Geraete, Freigaben und Protokolle
|
||||||
|
# wenig. Wer das nicht will, nimmt die Gruppe in einem Klick
|
||||||
|
# wieder weg.
|
||||||
|
from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
|
||||||
|
|
||||||
|
gruppe = rbac.funktionsgruppe_anlegen(
|
||||||
|
conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF
|
||||||
|
)
|
||||||
|
# Liegt sie im Papierkorb, kommt sie zurueck. Sonst haette dieser
|
||||||
|
# Aufruf nichts bewirkt: das Konto waere in einer geloeschten
|
||||||
|
# Gruppe -- und das faellt genau dann auf, wenn man es am
|
||||||
|
# wenigsten gebrauchen kann.
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE groups SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL",
|
||||||
|
(gruppe,),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
|
||||||
|
(user_id, gruppe),
|
||||||
|
)
|
||||||
audit.log(
|
audit.log(
|
||||||
conn,
|
conn,
|
||||||
actor="cli",
|
actor="cli",
|
||||||
actor_id=user_id,
|
actor_id=user_id,
|
||||||
action="user.admin_created",
|
action="user.admin_created",
|
||||||
target=username,
|
target=username,
|
||||||
detail=f"über die Kommandozeile {action}",
|
detail=(
|
||||||
|
f"über die Kommandozeile {action}, Gruppe "
|
||||||
|
f"{FUNKTIONEN_VOLLZUGRIFF.name} zugewiesen"
|
||||||
|
),
|
||||||
severity="warning",
|
severity="warning",
|
||||||
)
|
)
|
||||||
print(f"Administrator {username!r} {action}.")
|
print(f"Administrator {username!r} {action}.")
|
||||||
@@ -216,6 +244,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 +563,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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
@@ -30,14 +30,37 @@ 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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: 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
|
READ_CHUNK = 65536
|
||||||
DEFAULT_COMMAND_TIMEOUT = 20
|
DEFAULT_COMMAND_TIMEOUT = 20
|
||||||
POE_SETTLE_SECONDS = 2
|
POE_SETTLE_SECONDS = 2
|
||||||
@@ -104,7 +127,42 @@ class SwitchCli:
|
|||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
def close(self) -> None:
|
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:
|
try:
|
||||||
if closer is not None:
|
if closer is not None:
|
||||||
closer.close()
|
closer.close()
|
||||||
@@ -116,28 +174,62 @@ class SwitchCli:
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def _read_until_prompt(self, *, timeout: int, initial: bool = False) -> str:
|
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
|
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
|
||||||
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
|
||||||
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:
|
||||||
|
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
|
return text
|
||||||
|
|
||||||
def send(
|
def send(
|
||||||
@@ -212,6 +304,17 @@ class SwitchCli:
|
|||||||
)
|
)
|
||||||
result.transcript = self.full_transcript
|
result.transcript = self.full_transcript
|
||||||
return result
|
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}")
|
result.add(f"Zustand im ausgeschalteten Zustand: {off_state}")
|
||||||
|
|
||||||
self.set_poe(port, enabled=True)
|
self.set_poe(port, enabled=True)
|
||||||
@@ -224,6 +327,17 @@ class SwitchCli:
|
|||||||
result.transcript = self.full_transcript
|
result.transcript = self.full_transcript
|
||||||
return result
|
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
|
result.ok = True
|
||||||
except SshError as exc:
|
except SshError as exc:
|
||||||
result.error = str(exc)
|
result.error = str(exc)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 (?,?)",
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in new issue
Block a user