Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fd78ac26b | ||
|
|
86c09d8a0f | ||
|
|
70f6b005a9 | ||
|
|
57eb7764cc | ||
|
|
30c2c56656 | ||
|
|
292ea622a7 |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm"
|
name = "tesm"
|
||||||
version = "2.8.0"
|
version = "2.8.6"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -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]
|
||||||
|
"""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}))
|
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:
|
||||||
|
|||||||
@@ -216,6 +216,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 +535,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",
|
||||||
|
|||||||
@@ -16,10 +16,20 @@ eingelesen. Ein eigener Schreibpfad fuer Altdaten waere eine zweite Stelle, an
|
|||||||
der Fremdeingaben in die Datenbank gelangen -- und die zweite Stelle ist die,
|
der Fremdeingaben in die Datenbank gelangen -- und die zweite Stelle ist die,
|
||||||
die niemand pflegt.
|
die niemand pflegt.
|
||||||
|
|
||||||
Uebersetzt werden Clients, Switche, Zugangsdaten und die DHCP-Konfiguration.
|
Uebersetzt werden Clients, Switche, Zugangsdaten, die DHCP-Konfiguration, die
|
||||||
Was in der Datei sonst noch steht, wird nicht stillschweigend verworfen,
|
Freigabe-Zuordnungen und die Anbindung an das Verzeichnis (Server, Transport,
|
||||||
sondern im Bericht benannt -- damit sichtbar bleibt, was von Hand nachzuziehen
|
Basis-DN, Anmeldeattribut, Bind-Konto samt Passwort, und die Zuordnung
|
||||||
ist.
|
"diese AD-Gruppe wird Administrator"). Was in der Datei sonst noch steht, wird nicht
|
||||||
|
stillschweigend verworfen, sondern im Bericht benannt -- damit sichtbar bleibt,
|
||||||
|
was von Hand nachzuziehen ist.
|
||||||
|
|
||||||
|
Die Freigaben standen bis 2.8.0 auf der Liste "von Hand nachtragen", obwohl es
|
||||||
|
dafuer keinen Grund gab: der Vorgaenger fuehrt sie in
|
||||||
|
``ldap_fileshare_mappings`` mit denselben vier Angaben, die diese Anwendung in
|
||||||
|
``fileshare_mappings`` braucht (Verzeichnisgruppe, deren Name, Beschriftung,
|
||||||
|
UNC-Pfad), und einen Import dafuer gibt es hier ohnehin. Was sich uebernehmen
|
||||||
|
laesst, soll man nicht abtippen muessen -- ein UNC-Pfad von Hand ist ein
|
||||||
|
Tippfehler, der erst auffaellt, wenn jemand die Freigabe braucht.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -36,7 +46,7 @@ LEGACY_KDF_ITERATIONS = 390000
|
|||||||
LEGACY_APP_TAG = "tesm"
|
LEGACY_APP_TAG = "tesm"
|
||||||
|
|
||||||
#: Diese Abschnitte werden uebersetzt.
|
#: Diese Abschnitte werden uebersetzt.
|
||||||
SUPPORTED = ("credentials", "switches", "devices", "dhcp")
|
SUPPORTED = ("credentials", "switches", "devices", "dhcp", "ldap")
|
||||||
|
|
||||||
#: Was der alte Export sonst enthaelt. Wird gemeldet, nicht uebersetzt.
|
#: Was der alte Export sonst enthaelt. Wird gemeldet, nicht uebersetzt.
|
||||||
KNOWN_UNSUPPORTED: dict[str, str] = {
|
KNOWN_UNSUPPORTED: dict[str, str] = {
|
||||||
@@ -46,10 +56,9 @@ KNOWN_UNSUPPORTED: dict[str, str] = {
|
|||||||
"Zweck. Konten neu anlegen."
|
"Zweck. Konten neu anlegen."
|
||||||
),
|
),
|
||||||
"groups": "Gruppen und Rechte: die Rechteschlüssel wurden neu geschnitten.",
|
"groups": "Gruppen und Rechte: die Rechteschlüssel wurden neu geschnitten.",
|
||||||
"ldap": (
|
# Der Abschnitt "ldap" wird uebersetzt (siehe _verzeichnis) -- was dabei
|
||||||
"Verzeichnisdienst: Einstellungen, Gruppen- und Freigabe-Zuordnungen von "
|
# nicht mitkommt, benennt die Uebersetzung einzeln und genauer, als eine
|
||||||
"Hand übertragen."
|
# pauschale Zeile hier es koennte.
|
||||||
),
|
|
||||||
"nginx": "Webserver: Domain, Ports und Zertifikat von Hand übertragen.",
|
"nginx": "Webserver: Domain, Ports und Zertifikat von Hand übertragen.",
|
||||||
"logs": "Auditlog: bleibt bewusst beim alten System.",
|
"logs": "Auditlog: bleibt bewusst beim alten System.",
|
||||||
"known_hosts": (
|
"known_hosts": (
|
||||||
@@ -272,6 +281,168 @@ def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> l
|
|||||||
return ergebnis
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def _verzeichnis(data: Any, report: Report) -> dict[str, Any]:
|
||||||
|
"""Die Anbindung an das Verzeichnis -- so weit sie sich abbilden laesst.
|
||||||
|
|
||||||
|
Zwei Stellen sind **nicht** 1:1, und beide werden benannt statt geraten:
|
||||||
|
|
||||||
|
* **Transport.** Der Vorgaenger kannte ``ldap_use_ssl`` (an/aus). Aus "an"
|
||||||
|
wird LDAPS. Aus "aus" wird **StartTLS auf demselben Port** und nicht
|
||||||
|
"unverschluesselt": diese Anwendung verlangt fuer eine unverschluesselte
|
||||||
|
Verbindung einen ausdruecklichen Schalter, weil das Passwort dabei im
|
||||||
|
Klartext ueber das Netz geht. Diesen Schalter setzt eine Uebernahme
|
||||||
|
nicht fuer jemanden -- ein Active Directory beantwortet StartTLS auf 389
|
||||||
|
ohnehin, und wenn nicht, sagt der Verbindungstest es sofort.
|
||||||
|
* **Standard- und Anmeldegruppe.** Es gibt sie hier nicht als
|
||||||
|
Einstellung: die Standardgruppe ist ein Kennzeichen an der Gruppe
|
||||||
|
selbst, und eine erzwungene Anmeldegruppe kennt diese Anwendung nicht.
|
||||||
|
Waren sie gesetzt, steht es im Bericht.
|
||||||
|
"""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {}
|
||||||
|
alt = {str(k): v for k, v in (data.get("settings") or {}).items()}
|
||||||
|
ergebnis: dict[str, Any] = {}
|
||||||
|
einstellungen: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def ja(schluessel: str) -> bool:
|
||||||
|
return str(alt.get(schluessel) or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
server = str(alt.get("ldap_server") or "").strip()
|
||||||
|
if server:
|
||||||
|
einstellungen["ldap_host"] = server
|
||||||
|
if str(alt.get("ldap_port") or "").strip().isdigit():
|
||||||
|
einstellungen["ldap_port"] = int(str(alt["ldap_port"]).strip())
|
||||||
|
if "ldap_enabled" in alt:
|
||||||
|
einstellungen["ldap_enabled"] = ja("ldap_enabled")
|
||||||
|
if str(alt.get("ldap_base_dn") or "").strip():
|
||||||
|
einstellungen["ldap_base_dn"] = str(alt["ldap_base_dn"]).strip()
|
||||||
|
if str(alt.get("ldap_user_filter_attr") or "").strip():
|
||||||
|
einstellungen["ldap_user_attribute"] = str(alt["ldap_user_filter_attr"]).strip()
|
||||||
|
|
||||||
|
if "ldap_use_ssl" in alt:
|
||||||
|
if ja("ldap_use_ssl"):
|
||||||
|
einstellungen["ldap_security"] = "ldaps"
|
||||||
|
else:
|
||||||
|
einstellungen["ldap_security"] = "starttls"
|
||||||
|
report.note(
|
||||||
|
"Der Vorgänger hat unverschlüsselt verbunden. Übernommen als StartTLS auf "
|
||||||
|
"demselben Port – eine unverschlüsselte Verbindung verlangt hier einen "
|
||||||
|
"ausdrücklichen Schalter, den eine Übernahme nicht für Sie setzt. Bitte den "
|
||||||
|
"Verbindungstest ausführen."
|
||||||
|
)
|
||||||
|
if ja("ldap_tls_skip_verify"):
|
||||||
|
einstellungen["ldap_verify_cert"] = False
|
||||||
|
report.note(
|
||||||
|
"Die Prüfung des Serverzertifikats war abgeschaltet und bleibt es – sonst "
|
||||||
|
"scheitert die Anmeldung nach dem Upgrade. Damit schützt die Verschlüsselung "
|
||||||
|
"nur gegen Mitlesen, nicht gegen einen Man-in-the-Middle: bitte ein "
|
||||||
|
"CA-Zertifikat hinterlegen und die Prüfung einschalten."
|
||||||
|
)
|
||||||
|
if einstellungen:
|
||||||
|
ergebnis["settings"] = einstellungen
|
||||||
|
|
||||||
|
for schluessel, text in (
|
||||||
|
(
|
||||||
|
"ldap_default_group",
|
||||||
|
"Standardgruppe {wert!r}: hier ist das ein Kennzeichen an der Gruppe selbst "
|
||||||
|
"(Verwaltung -> Gruppen). Bitte dort setzen.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ldap_required_login_group",
|
||||||
|
"Erzwungene Anmeldegruppe {wert!r}: diese Anwendung kennt sie nicht. Wer sich "
|
||||||
|
"anmelden darf, entscheidet die Gruppen-Zuordnung.",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
wert = str(alt.get(schluessel) or "").strip()
|
||||||
|
if wert:
|
||||||
|
report.note(text.format(wert=wert))
|
||||||
|
|
||||||
|
benutzer = str(data.get("bind_username") or alt.get("ldap_bind_dn") or "").strip()
|
||||||
|
passwort = str(data.get("bind_password") or "")
|
||||||
|
if benutzer:
|
||||||
|
ergebnis["bind_username"] = benutzer
|
||||||
|
ergebnis["bind_password"] = passwort
|
||||||
|
if not passwort:
|
||||||
|
report.note(
|
||||||
|
f"Bind-Konto {benutzer!r} übernommen, aber ohne Passwort – es stand nicht in "
|
||||||
|
"der Datei. Unter Verwaltung -> Verzeichnisdienst nachtragen."
|
||||||
|
)
|
||||||
|
|
||||||
|
zuordnungen: list[dict[str, Any]] = []
|
||||||
|
offen: list[str] = []
|
||||||
|
for eintrag in _liste(data.get("group_mappings")):
|
||||||
|
dn = str(eintrag.get("ad_group_dn") or "").strip()
|
||||||
|
if not dn:
|
||||||
|
continue
|
||||||
|
name = str(eintrag.get("ad_group_name") or "").strip() or dn
|
||||||
|
ziel = str(
|
||||||
|
eintrag.get("app_group_name") or eintrag.get("app_group_id") or ""
|
||||||
|
).strip()
|
||||||
|
# Der Vorgaenger kannte den Sonderwert "admin" (bzw. "Admin" im
|
||||||
|
# Export): diese AD-Gruppe macht Administratoren. Genau dafuer gibt es
|
||||||
|
# hier seit 2.8.0 ein Ziel -- das Administratorkennzeichen.
|
||||||
|
if ziel.lower() == "admin":
|
||||||
|
zuordnungen.append(
|
||||||
|
{"ad_group_dn": dn, "ad_group_name": name, "grants_admin": True}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if ziel:
|
||||||
|
offen.append(f"{name} -> {ziel}")
|
||||||
|
if zuordnungen:
|
||||||
|
ergebnis["group_mappings"] = zuordnungen
|
||||||
|
if offen:
|
||||||
|
report.note(
|
||||||
|
"Diese Gruppen-Zuordnungen zeigen auf lokale Gruppen, die nicht mit übernommen "
|
||||||
|
"werden (die Rechteschlüssel sind neu geschnitten): "
|
||||||
|
+ ", ".join(sorted(offen))
|
||||||
|
+ ". Nach dem Anlegen der Gruppen unter Verwaltung -> Verzeichnisdienst "
|
||||||
|
"nachtragen."
|
||||||
|
)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def _freigaben(data: Any, report: Report) -> list[dict[str, Any]]:
|
||||||
|
"""Freigabe-Zuordnungen aus dem Abschnitt "ldap" des alten Exports.
|
||||||
|
|
||||||
|
Der Vorgaenger legt sie unter ``ldap.fileshare_mappings`` ab; beim Lesen
|
||||||
|
einer Installation stehen sie unmittelbar in der Tabelle. Beides landet
|
||||||
|
hier, damit es nur eine Stelle gibt, die die Felder umbenennt.
|
||||||
|
"""
|
||||||
|
if isinstance(data, dict):
|
||||||
|
eintraege = _liste(data.get("fileshare_mappings"))
|
||||||
|
else:
|
||||||
|
eintraege = _liste(data)
|
||||||
|
ergebnis: list[dict[str, Any]] = []
|
||||||
|
ohne_pfad = 0
|
||||||
|
for eintrag in eintraege:
|
||||||
|
# "share_label"/"share_unc" heissen hier "label"/"unc" -- dieselbe
|
||||||
|
# Angabe, kuerzerer Name, weil die Tabelle schon "fileshare" heisst.
|
||||||
|
unc = str(eintrag.get("share_unc") or eintrag.get("unc") or "").strip()
|
||||||
|
gruppe = str(eintrag.get("ad_group_dn") or "").strip()
|
||||||
|
if not unc or not gruppe:
|
||||||
|
ohne_pfad += 1
|
||||||
|
continue
|
||||||
|
beschriftung = str(eintrag.get("share_label") or eintrag.get("label") or "").strip()
|
||||||
|
ergebnis.append(
|
||||||
|
{
|
||||||
|
"ad_group_dn": gruppe,
|
||||||
|
"ad_group_name": str(eintrag.get("ad_group_name") or "").strip() or gruppe,
|
||||||
|
# Ohne Beschriftung der letzte Teil des Pfades -- besser als
|
||||||
|
# eine Freigabe ohne Namen. Beide Trenner: der Vorgaenger
|
||||||
|
# speichert "//server/share", getippt wird "\server\share".
|
||||||
|
"label": beschriftung
|
||||||
|
or unc.replace(chr(92), "/").rstrip("/").rsplit("/", 1)[-1],
|
||||||
|
"unc": unc,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if ohne_pfad:
|
||||||
|
report.note(
|
||||||
|
f"{ohne_pfad} Freigabe-Zuordnung(en) ohne Verzeichnisgruppe oder UNC-Pfad "
|
||||||
|
"übersprungen."
|
||||||
|
)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
|
def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
|
||||||
"""DHCP-Teil uebersetzen. Rueckgabe: ``(dhcp, settings)``."""
|
"""DHCP-Teil uebersetzen. Rueckgabe: ``(dhcp, settings)``."""
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
@@ -382,6 +553,14 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
|
|||||||
if devices:
|
if devices:
|
||||||
payload["devices"] = devices
|
payload["devices"] = devices
|
||||||
|
|
||||||
|
freigaben = _freigaben(legacy.get("ldap") or legacy.get("fileshare"), report)
|
||||||
|
if freigaben:
|
||||||
|
payload["fileshare"] = freigaben
|
||||||
|
|
||||||
|
verzeichnis = _verzeichnis(legacy.get("ldap"), report)
|
||||||
|
if verzeichnis:
|
||||||
|
payload["directory"] = verzeichnis
|
||||||
|
|
||||||
dhcp, settings = _dhcp(legacy.get("dhcp"), report)
|
dhcp, settings = _dhcp(legacy.get("dhcp"), report)
|
||||||
if dhcp:
|
if dhcp:
|
||||||
payload["dhcp"] = dhcp
|
payload["dhcp"] = dhcp
|
||||||
@@ -417,8 +596,8 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
|
|||||||
|
|
||||||
if not payload:
|
if not payload:
|
||||||
raise LegacyTransferError(
|
raise LegacyTransferError(
|
||||||
"Die Datei enthält keine Clients, Switche, Zugangsdaten oder DHCP-Daten. "
|
"Die Datei enthält keine Clients, Switche, Zugangsdaten, DHCP-Daten oder "
|
||||||
"Beim Export im alten TESM diese Kategorien mitwählen."
|
"Freigaben. Beim Export im alten TESM diese Kategorien mitwählen."
|
||||||
)
|
)
|
||||||
return payload, report
|
return payload, report
|
||||||
|
|
||||||
@@ -617,8 +796,9 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
|
|||||||
continue
|
continue
|
||||||
payload[abschnitt] = [dict(row) for row in conn.execute(sql).fetchall()]
|
payload[abschnitt] = [dict(row) for row in conn.execute(sql).fetchall()]
|
||||||
|
|
||||||
|
entschluesseln_bind = _entschluessler(root, report)
|
||||||
if "credentials" in vorhanden:
|
if "credentials" in vorhanden:
|
||||||
entschluesseln = _entschluessler(root, report)
|
entschluesseln = entschluesseln_bind
|
||||||
payload["credentials"] = [
|
payload["credentials"] = [
|
||||||
{
|
{
|
||||||
"name": row["name"],
|
"name": row["name"],
|
||||||
@@ -634,6 +814,80 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
|
|||||||
else:
|
else:
|
||||||
report.note("Tabelle 'credentials' fehlt in der alten Datenbank.")
|
report.note("Tabelle 'credentials' fehlt in der alten Datenbank.")
|
||||||
|
|
||||||
|
# Der Abschnitt "ldap" -- genau so aufgebaut wie im alten Export, damit
|
||||||
|
# die Uebersetzung beide Wege gleich liest.
|
||||||
|
ldap: dict[str, Any] = {}
|
||||||
|
if "ldap_fileshare_mappings" in vorhanden:
|
||||||
|
freigaben = [
|
||||||
|
dict(row)
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT ad_group_dn, ad_group_name, share_label, share_unc "
|
||||||
|
"FROM ldap_fileshare_mappings"
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
if freigaben:
|
||||||
|
ldap["fileshare_mappings"] = freigaben
|
||||||
|
else:
|
||||||
|
report.note("Tabelle 'ldap_fileshare_mappings' fehlt in der alten Datenbank.")
|
||||||
|
|
||||||
|
if "settings" in vorhanden:
|
||||||
|
# Nur anlegen, wenn etwas drinsteht: ein leerer Abschnitt sieht im
|
||||||
|
# Bericht wie eine Uebernahme aus und ist keine.
|
||||||
|
ldap_einstellungen = {
|
||||||
|
str(row["key"]): str(row["value"])
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT key, value FROM settings WHERE key LIKE 'ldap%'"
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
if ldap_einstellungen:
|
||||||
|
ldap["settings"] = ldap_einstellungen
|
||||||
|
|
||||||
|
if "ldap_group_mappings" in vorhanden:
|
||||||
|
# Der Sonderwert "admin" steht in derselben Spalte wie eine
|
||||||
|
# Gruppen-Id; der Name dahinter ist das, was ueber die
|
||||||
|
# Systemgrenze traegt.
|
||||||
|
gruppen_da = "groups" in vorhanden
|
||||||
|
zuordnungen = [
|
||||||
|
{
|
||||||
|
"ad_group_dn": str(row["ad_group_dn"]),
|
||||||
|
"ad_group_name": str(row["ad_group_name"]),
|
||||||
|
"app_group_name": str(row["ziel"] or ""),
|
||||||
|
}
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT m.ad_group_dn, m.ad_group_name, CASE "
|
||||||
|
" WHEN m.app_group_id = 'admin' THEN 'admin' "
|
||||||
|
+ (" ELSE g.name " if gruppen_da else " ELSE NULL ")
|
||||||
|
+ "END AS ziel FROM ldap_group_mappings m"
|
||||||
|
+ (
|
||||||
|
" LEFT JOIN groups g ON g.id = m.app_group_id"
|
||||||
|
if gruppen_da
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
if zuordnungen:
|
||||||
|
ldap["group_mappings"] = zuordnungen
|
||||||
|
|
||||||
|
# Das Bind-Konto: erst das Dienstkonto (so liest es auch der alte
|
||||||
|
# Export), sonst die beiden Einstellungen aus aelteren Fassungen.
|
||||||
|
if "service_accounts" in vorhanden:
|
||||||
|
konto = _eine(
|
||||||
|
conn,
|
||||||
|
"SELECT username, password FROM service_accounts WHERE purpose='ldap_bind'",
|
||||||
|
)
|
||||||
|
if konto is not None:
|
||||||
|
ldap["bind_username"] = str(konto["username"] or "")
|
||||||
|
ldap["bind_password"] = entschluesseln_bind(str(konto["password"] or ""))
|
||||||
|
if not ldap.get("bind_username"):
|
||||||
|
aus_einstellungen = (ldap.get("settings") or {}).get("ldap_bind_dn") or ""
|
||||||
|
if aus_einstellungen:
|
||||||
|
ldap["bind_username"] = str(aus_einstellungen)
|
||||||
|
ldap["bind_password"] = entschluesseln_bind(
|
||||||
|
str((ldap.get("settings") or {}).get("ldap_bind_password") or "")
|
||||||
|
)
|
||||||
|
if ldap:
|
||||||
|
payload["ldap"] = ldap
|
||||||
|
|
||||||
dhcp = _alt_dhcp(conn, vorhanden)
|
dhcp = _alt_dhcp(conn, vorhanden)
|
||||||
if dhcp:
|
if dhcp:
|
||||||
payload["dhcp"] = dhcp
|
payload["dhcp"] = dhcp
|
||||||
@@ -644,9 +898,16 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
|
|||||||
if not payload:
|
if not payload:
|
||||||
raise LegacyTransferError(
|
raise LegacyTransferError(
|
||||||
f"In {root} stehen keine übernehmbaren Daten (Clients, Switche, "
|
f"In {root} stehen keine übernehmbaren Daten (Clients, Switche, "
|
||||||
"Zugangsdaten, DHCP)."
|
"Zugangsdaten, DHCP, Freigaben)."
|
||||||
)
|
)
|
||||||
for abschnitt, inhalt in payload.items():
|
for abschnitt, inhalt in payload.items():
|
||||||
|
if abschnitt == "ldap" and isinstance(inhalt, dict):
|
||||||
|
report.took("Freigaben", len(_liste(inhalt.get("fileshare_mappings"))))
|
||||||
|
report.took("Verzeichniseinstellungen", len(inhalt.get("settings") or {}))
|
||||||
|
report.took("Gruppen-Zuordnungen", len(_liste(inhalt.get("group_mappings"))))
|
||||||
|
if inhalt.get("bind_username"):
|
||||||
|
report.took("Bind-Konto", 1)
|
||||||
|
continue
|
||||||
report.took(abschnitt, len(inhalt) if isinstance(inhalt, list) else 1)
|
report.took(abschnitt, len(inhalt) if isinstance(inhalt, list) else 1)
|
||||||
return payload, report
|
return payload, report
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ CATEGORIES: tuple[Category, ...] = (
|
|||||||
),
|
),
|
||||||
Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"),
|
Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"),
|
||||||
Category("fileshare", "Freigabe-Zuordnungen", "Verzeichnisgruppe zu UNC-Pfad"),
|
Category("fileshare", "Freigabe-Zuordnungen", "Verzeichnisgruppe zu UNC-Pfad"),
|
||||||
|
Category(
|
||||||
|
"directory",
|
||||||
|
"Verzeichnisdienst",
|
||||||
|
"LDAP-Anbindung, Bind-Konto und Gruppen-Zuordnungen. Das Passwort des "
|
||||||
|
"Bind-Kontos nur mit \"Geheimnisse einschliessen\".",
|
||||||
|
admin_only=True,
|
||||||
|
sensitive=True,
|
||||||
|
),
|
||||||
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
|
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
|
||||||
Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True),
|
Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True),
|
||||||
Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True),
|
Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True),
|
||||||
@@ -186,6 +194,49 @@ def build_export(
|
|||||||
}
|
}
|
||||||
for row in Database.all(conn, "SELECT * FROM groups WHERE deleted_at IS NULL")
|
for row in Database.all(conn, "SELECT * FROM groups WHERE deleted_at IS NULL")
|
||||||
]
|
]
|
||||||
|
if "directory" in wanted:
|
||||||
|
# Die Anbindung an das Verzeichnis war das einzige Stueck Konfiguration,
|
||||||
|
# das man nach einer Wiederherstellung von Hand nachbauen musste --
|
||||||
|
# inklusive Bind-Passwort, das dabei jemand aus einem Kennwortspeicher
|
||||||
|
# holen muss. Es gehoert zur Anlage wie die Netzwerkeinstellung.
|
||||||
|
from tesm_core.auth import directory as verzeichnis
|
||||||
|
|
||||||
|
bind_benutzer, bind_passwort = verzeichnis.bind_credentials(conn)
|
||||||
|
gesetzt = {
|
||||||
|
str(row["key"]): str(row["value"])
|
||||||
|
for row in Database.all(conn, "SELECT key, value FROM settings")
|
||||||
|
}
|
||||||
|
payload["directory"] = {
|
||||||
|
# Nur die Schluessel der Anbindung, und nur die, die wirklich
|
||||||
|
# gesetzt sind: ein Vorgabewert in der Datei sieht aus wie eine
|
||||||
|
# Entscheidung und ueberschreibt beim Einlesen eine echte.
|
||||||
|
"settings": {
|
||||||
|
spec.key: gesetzt[spec.key] for spec in verzeichnis.SETTINGS
|
||||||
|
if spec.key in gesetzt
|
||||||
|
},
|
||||||
|
"bind_username": bind_benutzer,
|
||||||
|
# Ohne "Geheimnisse einschliessen" reist das Passwort nicht mit.
|
||||||
|
# Der Benutzername schon: er ist keine Geheimnis, und ohne ihn
|
||||||
|
# muesste man die halbe Anbindung doch wieder tippen.
|
||||||
|
"bind_password": bind_passwort if include_secrets else "",
|
||||||
|
# Die Zuordnung zeigt auf eine Gruppe **oder** auf das
|
||||||
|
# Administratorkennzeichen. Ueber eine Systemgrenze hinweg ist eine
|
||||||
|
# Gruppen-Id nichts wert -- der Name ist es.
|
||||||
|
"group_mappings": [
|
||||||
|
{
|
||||||
|
"ad_group_dn": str(row["ad_group_dn"]),
|
||||||
|
"ad_group_name": str(row["ad_group_name"]),
|
||||||
|
"group_name": str(row["group_name"] or ""),
|
||||||
|
"grants_admin": bool(row["grants_admin"]),
|
||||||
|
}
|
||||||
|
for row in Database.all(
|
||||||
|
conn,
|
||||||
|
"SELECT m.ad_group_dn, m.ad_group_name, m.grants_admin, g.name AS group_name "
|
||||||
|
"FROM ldap_group_mappings m "
|
||||||
|
"LEFT JOIN groups g ON g.id = m.app_group_id AND g.deleted_at IS NULL",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
if "settings" in wanted:
|
if "settings" in wanted:
|
||||||
payload["settings"] = {
|
payload["settings"] = {
|
||||||
str(row["key"]): str(row["value"])
|
str(row["key"]): str(row["value"])
|
||||||
@@ -534,19 +585,33 @@ def apply_import(
|
|||||||
|
|
||||||
if "fileshare" in wanted:
|
if "fileshare" in wanted:
|
||||||
for entry in payload.get("fileshare", []):
|
for entry in payload.get("fileshare", []):
|
||||||
|
gruppe = str(entry.get("ad_group_dn") or "")
|
||||||
|
pfad = str(entry.get("unc") or "")
|
||||||
|
if not gruppe or not pfad:
|
||||||
|
bump("fileshare", "skipped")
|
||||||
|
continue
|
||||||
|
# Vorher nachsehen, damit der Bericht die Wahrheit sagt: bisher
|
||||||
|
# stand dort auch bei einer frischen Installation "2 aktualisiert",
|
||||||
|
# obwohl zwei Zuordnungen **angelegt** wurden. Wer einen Import
|
||||||
|
# prueft, liest genau diese Zahlen.
|
||||||
|
vorhanden = Database.value(
|
||||||
|
conn,
|
||||||
|
"SELECT 1 FROM fileshare_mappings WHERE ad_group_dn=? AND unc=? LIMIT 1",
|
||||||
|
(gruppe, pfad),
|
||||||
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
|
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
|
||||||
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO UPDATE SET "
|
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO UPDATE SET "
|
||||||
"label=excluded.label, ad_group_name=excluded.ad_group_name",
|
"label=excluded.label, ad_group_name=excluded.ad_group_name",
|
||||||
(
|
(
|
||||||
str(entry.get("ad_group_dn") or ""),
|
gruppe,
|
||||||
str(entry.get("ad_group_name") or ""),
|
str(entry.get("ad_group_name") or ""),
|
||||||
str(entry.get("label") or ""),
|
str(entry.get("label") or ""),
|
||||||
str(entry.get("unc") or ""),
|
pfad,
|
||||||
stamp,
|
stamp,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
bump("fileshare", "updated")
|
bump("fileshare", "updated" if vorhanden else "created")
|
||||||
|
|
||||||
if "groups" in wanted:
|
if "groups" in wanted:
|
||||||
for entry in payload.get("groups", []):
|
for entry in payload.get("groups", []):
|
||||||
@@ -690,6 +755,63 @@ def apply_import(
|
|||||||
)
|
)
|
||||||
bump("signatures", "created")
|
bump("signatures", "created")
|
||||||
|
|
||||||
|
if "directory" in wanted:
|
||||||
|
from tesm_core.auth import directory as verzeichnis
|
||||||
|
|
||||||
|
abschnitt = payload.get("directory") or {}
|
||||||
|
bekannt = {spec.key for spec in verzeichnis.SETTINGS}
|
||||||
|
for key, value in (abschnitt.get("settings") or {}).items():
|
||||||
|
if str(key) not in bekannt:
|
||||||
|
# Ein fremder Schluessel in diesem Abschnitt gehoert nicht hier
|
||||||
|
# hin; die Kategorie "Einstellungen" nimmt alles andere.
|
||||||
|
bump("directory", "skipped")
|
||||||
|
continue
|
||||||
|
set_setting(str(key), value)
|
||||||
|
bump("directory", "updated")
|
||||||
|
|
||||||
|
benutzer = str(abschnitt.get("bind_username") or "").strip()
|
||||||
|
passwort = str(abschnitt.get("bind_password") or "")
|
||||||
|
if benutzer:
|
||||||
|
# Ohne Passwort bleibt ein vorhandenes stehen -- genau dafuer nimmt
|
||||||
|
# store_bind_credentials None. Eine Sicherung ohne Geheimnisse darf
|
||||||
|
# das Passwort nicht loeschen.
|
||||||
|
verzeichnis.store_bind_credentials(conn, benutzer, passwort or None)
|
||||||
|
bump("directory", "updated")
|
||||||
|
|
||||||
|
for eintrag in abschnitt.get("group_mappings") or []:
|
||||||
|
dn = str(eintrag.get("ad_group_dn") or "").strip()
|
||||||
|
if not dn:
|
||||||
|
bump("directory", "skipped")
|
||||||
|
continue
|
||||||
|
kennzeichen = bool(eintrag.get("grants_admin"))
|
||||||
|
gruppe = None
|
||||||
|
if not kennzeichen:
|
||||||
|
name = str(eintrag.get("group_name") or "").strip()
|
||||||
|
gruppe = Database.value(
|
||||||
|
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (name,)
|
||||||
|
)
|
||||||
|
if gruppe is None:
|
||||||
|
# Eine Zuordnung auf eine Gruppe, die es hier nicht gibt,
|
||||||
|
# waere eine Zuordnung ins Leere: beim naechsten Login
|
||||||
|
# bekaeme das Konto nichts, und niemand wuesste warum.
|
||||||
|
bump("directory", "skipped")
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO ldap_group_mappings "
|
||||||
|
"(ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
|
||||||
|
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn) DO UPDATE SET "
|
||||||
|
"ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id, "
|
||||||
|
"grants_admin=excluded.grants_admin",
|
||||||
|
(
|
||||||
|
dn,
|
||||||
|
str(eintrag.get("ad_group_name") or dn),
|
||||||
|
int(gruppe) if gruppe is not None else None,
|
||||||
|
int(kennzeichen),
|
||||||
|
stamp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
bump("directory", "created")
|
||||||
|
|
||||||
if "settings" in wanted:
|
if "settings" in wanted:
|
||||||
for key, value in (payload.get("settings") or {}).items():
|
for key, value in (payload.get("settings") or {}).items():
|
||||||
if str(key).startswith("license_"):
|
if str(key).startswith("license_"):
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.8.0"
|
version = "2.8.2"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -58,6 +58,30 @@ class SettingSpec:
|
|||||||
return str(raw)
|
return str(raw)
|
||||||
|
|
||||||
def serialize(self, value: Any) -> str:
|
def serialize(self, value: Any) -> str:
|
||||||
|
"""Ein Wert als Text fuer die Datenbank.
|
||||||
|
|
||||||
|
Der Wert kann als Python-Wert kommen (aus einem Formular, aus dem
|
||||||
|
Code) -- oder als die Zeichenkette, die schon in der Datenbank stand.
|
||||||
|
Genau so liest eine **Sicherung** ihn wieder ein, und genau dort lag
|
||||||
|
ein stiller Fehler: ``serialize("0")`` ergab ``"1"``, weil jede nicht
|
||||||
|
leere Zeichenkette wahr ist. Aus jedem abgeschalteten Schalter wurde
|
||||||
|
beim Einspielen ein eingeschalteter -- auch aus
|
||||||
|
``ldap_allow_insecure``. Ein JSON-Feld wurde ein zweites Mal kodiert,
|
||||||
|
und aus der Navigationsreihenfolge damit eine Zeichenkette.
|
||||||
|
|
||||||
|
Deshalb: eine Zeichenkette erst lesen. Was hier herauskommt, muss
|
||||||
|
durch ``serialize`` unveraendert hindurchgehen -- sonst ueberlebt kein
|
||||||
|
Wert eine Sicherung.
|
||||||
|
"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
if self.kind == "bool":
|
||||||
|
value = self.parse(value)
|
||||||
|
elif self.kind == "json":
|
||||||
|
try:
|
||||||
|
value = json.loads(value)
|
||||||
|
except ValueError:
|
||||||
|
# Kein JSON-Text, also ein Wert: dann wird er kodiert.
|
||||||
|
pass
|
||||||
if self.kind == "bool":
|
if self.kind == "bool":
|
||||||
return "1" if value else "0"
|
return "1" if value else "0"
|
||||||
if self.kind == "json":
|
if self.kind == "json":
|
||||||
|
|||||||
Reference in new issue
Block a user