Compare commits

...
3 Commits
6 changed files with 520 additions and 19 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm"
version = "2.8.0"
version = "2.8.3"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11"
dependencies = [
+274 -13
View File
@@ -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,
die niemand pflegt.
Uebersetzt werden Clients, Switche, Zugangsdaten und die DHCP-Konfiguration.
Was in der Datei sonst noch steht, wird nicht stillschweigend verworfen,
sondern im Bericht benannt -- damit sichtbar bleibt, was von Hand nachzuziehen
ist.
Uebersetzt werden Clients, Switche, Zugangsdaten, die DHCP-Konfiguration, die
Freigabe-Zuordnungen und die Anbindung an das Verzeichnis (Server, Transport,
Basis-DN, Anmeldeattribut, Bind-Konto samt Passwort, und die Zuordnung
"diese AD-Gruppe wird Administrator"). Was in der Datei sonst noch steht, wird nicht
stillschweigend verworfen, sondern im Bericht benannt -- damit sichtbar bleibt,
was von Hand nachzuziehen ist.
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
@@ -36,7 +46,7 @@ LEGACY_KDF_ITERATIONS = 390000
LEGACY_APP_TAG = "tesm"
#: 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.
KNOWN_UNSUPPORTED: dict[str, str] = {
@@ -46,10 +56,9 @@ KNOWN_UNSUPPORTED: dict[str, str] = {
"Zweck. Konten neu anlegen."
),
"groups": "Gruppen und Rechte: die Rechteschlüssel wurden neu geschnitten.",
"ldap": (
"Verzeichnisdienst: Einstellungen, Gruppen- und Freigabe-Zuordnungen von "
"Hand übertragen."
),
# Der Abschnitt "ldap" wird uebersetzt (siehe _verzeichnis) -- was dabei
# nicht mitkommt, benennt die Uebersetzung einzeln und genauer, als eine
# pauschale Zeile hier es koennte.
"nginx": "Webserver: Domain, Ports und Zertifikat von Hand übertragen.",
"logs": "Auditlog: bleibt bewusst beim alten System.",
"known_hosts": (
@@ -272,6 +281,168 @@ def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> l
return ergebnis
def _verzeichnis(data: Any, report: Report) -> dict[str, Any]:
"""Die Anbindung an das Verzeichnis -- so weit sie sich abbilden laesst.
Zwei Stellen sind **nicht** 1:1, und beide werden benannt statt geraten:
* **Transport.** Der Vorgaenger kannte ``ldap_use_ssl`` (an/aus). Aus "an"
wird LDAPS. Aus "aus" wird **StartTLS auf demselben Port** und nicht
"unverschluesselt": diese Anwendung verlangt fuer eine unverschluesselte
Verbindung einen ausdruecklichen Schalter, weil das Passwort dabei im
Klartext ueber das Netz geht. Diesen Schalter setzt eine Uebernahme
nicht fuer jemanden -- ein Active Directory beantwortet StartTLS auf 389
ohnehin, und wenn nicht, sagt der Verbindungstest es sofort.
* **Standard- und Anmeldegruppe.** Es gibt sie hier nicht als
Einstellung: die Standardgruppe ist ein Kennzeichen an der Gruppe
selbst, und eine erzwungene Anmeldegruppe kennt diese Anwendung nicht.
Waren sie gesetzt, steht es im Bericht.
"""
if not isinstance(data, dict):
return {}
alt = {str(k): v for k, v in (data.get("settings") or {}).items()}
ergebnis: dict[str, Any] = {}
einstellungen: dict[str, Any] = {}
def ja(schluessel: str) -> bool:
return str(alt.get(schluessel) or "").strip().lower() in {"1", "true", "yes", "on"}
server = str(alt.get("ldap_server") or "").strip()
if server:
einstellungen["ldap_host"] = server
if str(alt.get("ldap_port") or "").strip().isdigit():
einstellungen["ldap_port"] = int(str(alt["ldap_port"]).strip())
if "ldap_enabled" in alt:
einstellungen["ldap_enabled"] = ja("ldap_enabled")
if str(alt.get("ldap_base_dn") or "").strip():
einstellungen["ldap_base_dn"] = str(alt["ldap_base_dn"]).strip()
if str(alt.get("ldap_user_filter_attr") or "").strip():
einstellungen["ldap_user_attribute"] = str(alt["ldap_user_filter_attr"]).strip()
if "ldap_use_ssl" in alt:
if ja("ldap_use_ssl"):
einstellungen["ldap_security"] = "ldaps"
else:
einstellungen["ldap_security"] = "starttls"
report.note(
"Der Vorgänger hat unverschlüsselt verbunden. Übernommen als StartTLS auf "
"demselben Port – eine unverschlüsselte Verbindung verlangt hier einen "
"ausdrücklichen Schalter, den eine Übernahme nicht für Sie setzt. Bitte den "
"Verbindungstest ausführen."
)
if ja("ldap_tls_skip_verify"):
einstellungen["ldap_verify_cert"] = False
report.note(
"Die Prüfung des Serverzertifikats war abgeschaltet und bleibt es – sonst "
"scheitert die Anmeldung nach dem Upgrade. Damit schützt die Verschlüsselung "
"nur gegen Mitlesen, nicht gegen einen Man-in-the-Middle: bitte ein "
"CA-Zertifikat hinterlegen und die Prüfung einschalten."
)
if einstellungen:
ergebnis["settings"] = einstellungen
for schluessel, text in (
(
"ldap_default_group",
"Standardgruppe {wert!r}: hier ist das ein Kennzeichen an der Gruppe selbst "
"(Verwaltung -> Gruppen). Bitte dort setzen.",
),
(
"ldap_required_login_group",
"Erzwungene Anmeldegruppe {wert!r}: diese Anwendung kennt sie nicht. Wer sich "
"anmelden darf, entscheidet die Gruppen-Zuordnung.",
),
):
wert = str(alt.get(schluessel) or "").strip()
if wert:
report.note(text.format(wert=wert))
benutzer = str(data.get("bind_username") or alt.get("ldap_bind_dn") or "").strip()
passwort = str(data.get("bind_password") or "")
if benutzer:
ergebnis["bind_username"] = benutzer
ergebnis["bind_password"] = passwort
if not passwort:
report.note(
f"Bind-Konto {benutzer!r} übernommen, aber ohne Passwort – es stand nicht in "
"der Datei. Unter Verwaltung -> Verzeichnisdienst nachtragen."
)
zuordnungen: list[dict[str, Any]] = []
offen: list[str] = []
for eintrag in _liste(data.get("group_mappings")):
dn = str(eintrag.get("ad_group_dn") or "").strip()
if not dn:
continue
name = str(eintrag.get("ad_group_name") or "").strip() or dn
ziel = str(
eintrag.get("app_group_name") or eintrag.get("app_group_id") or ""
).strip()
# Der Vorgaenger kannte den Sonderwert "admin" (bzw. "Admin" im
# Export): diese AD-Gruppe macht Administratoren. Genau dafuer gibt es
# hier seit 2.8.0 ein Ziel -- das Administratorkennzeichen.
if ziel.lower() == "admin":
zuordnungen.append(
{"ad_group_dn": dn, "ad_group_name": name, "grants_admin": True}
)
continue
if ziel:
offen.append(f"{name} -> {ziel}")
if zuordnungen:
ergebnis["group_mappings"] = zuordnungen
if offen:
report.note(
"Diese Gruppen-Zuordnungen zeigen auf lokale Gruppen, die nicht mit übernommen "
"werden (die Rechteschlüssel sind neu geschnitten): "
+ ", ".join(sorted(offen))
+ ". Nach dem Anlegen der Gruppen unter Verwaltung -> Verzeichnisdienst "
"nachtragen."
)
return ergebnis
def _freigaben(data: Any, report: Report) -> list[dict[str, Any]]:
"""Freigabe-Zuordnungen aus dem Abschnitt "ldap" des alten Exports.
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]]:
"""DHCP-Teil uebersetzen. Rueckgabe: ``(dhcp, settings)``."""
if not isinstance(data, dict):
@@ -382,6 +553,14 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
if 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)
if dhcp:
payload["dhcp"] = dhcp
@@ -417,8 +596,8 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
if not payload:
raise LegacyTransferError(
"Die Datei enthält keine Clients, Switche, Zugangsdaten oder DHCP-Daten. "
"Beim Export im alten TESM diese Kategorien mitwählen."
"Die Datei enthält keine Clients, Switche, Zugangsdaten, DHCP-Daten oder "
"Freigaben. Beim Export im alten TESM diese Kategorien mitwählen."
)
return payload, report
@@ -617,8 +796,9 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
continue
payload[abschnitt] = [dict(row) for row in conn.execute(sql).fetchall()]
entschluesseln_bind = _entschluessler(root, report)
if "credentials" in vorhanden:
entschluesseln = _entschluessler(root, report)
entschluesseln = entschluesseln_bind
payload["credentials"] = [
{
"name": row["name"],
@@ -634,6 +814,80 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
else:
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)
if dhcp:
payload["dhcp"] = dhcp
@@ -644,9 +898,16 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
if not payload:
raise LegacyTransferError(
f"In {root} stehen keine übernehmbaren Daten (Clients, Switche, "
"Zugangsdaten, DHCP)."
"Zugangsdaten, DHCP, Freigaben)."
)
for abschnitt, inhalt in payload.items():
if abschnitt == "ldap" and isinstance(inhalt, dict):
report.took("Freigaben", len(_liste(inhalt.get("fileshare_mappings"))))
report.took("Verzeichniseinstellungen", len(inhalt.get("settings") or {}))
report.took("Gruppen-Zuordnungen", len(_liste(inhalt.get("group_mappings"))))
if inhalt.get("bind_username"):
report.took("Bind-Konto", 1)
continue
report.took(abschnitt, len(inhalt) if isinstance(inhalt, list) else 1)
return payload, report
+95 -1
View File
@@ -38,6 +38,12 @@ ERROR_RE = re.compile(
r"(invalid input|unknown command|incomplete|ambiguous|not supported|% *error)", re.IGNORECASE
)
#: Der Weg nach draussen -- zweimal "exit" fuehrt aus jedem Modus in den
#: privilegierten zurueck, das dritte beendet die Sitzung. Genau so macht es
#: das bewaehrte Skript der Vorgaengerfassung.
ABMELDUNG = ("exit", "exit", "exit")
ABMELDE_PAUSE = 0.15
READ_CHUNK = 65536
DEFAULT_COMMAND_TIMEOUT = 20
POE_SETTLE_SECONDS = 2
@@ -104,7 +110,42 @@ class SwitchCli:
self.close()
def close(self) -> None:
for closer in (self._channel, self._transport):
"""Meldet sich ab und legt dann erst auf.
Der entscheidende Teil, und der einzige, den das ``expect``-Skript der
Fassung 1.2.8 hatte und diese Fassung nicht: ``exit`` bis heraus aus
dem Konfigurationsmodus, dann ``exit``, und auf die Frage
"Do you want to log out (y/n)?" ein ``y``.
Warum das mehr ist als Hoeflichkeit: ArubaOS erlaubt nur eine Handvoll
gleichzeitiger SSH-Sitzungen (``show ip ssh``, Vorgabe fuenf) und
raeumt eine weggebrochene Verbindung **nicht sofort** ab. Wer die
Verbindung nur fallen laesst, belegt den Platz weiter. Nach ein paar
Vorgaengen -- ein Ueberwachungsdurchlauf mit mehreren Geraeten am
selben Switch reicht -- sind alle Plaetze belegt. Danach kommt eine
neue Sitzung noch durch Anmeldung und Authentisierung, bekommt aber
**keine Shell**: kein Prompt, keine Antwort, nichts. Im Betrieb sieht
das aus wie ein stummer Switch, und der Portzyklus passiert nicht.
Fehler werden hier geschluckt: eine Abmeldung, die nicht klappt, darf
das Ergebnis des Vorgangs nicht umwerfen -- aufgelegt wird ohnehin.
"""
kanal = self._channel
if kanal is not None:
try:
for zeile in ABMELDUNG:
kanal.send(zeile + "\r\n")
# Kurz warten, damit der Switch die Zeile verarbeitet --
# auf die Antwort zu warten wuerde beim Abmelden nur
# Zeitlimits sammeln.
time.sleep(ABMELDE_PAUSE)
# "Do you want to log out (y/n)?"
kanal.send("y\r\n")
time.sleep(ABMELDE_PAUSE)
except Exception: # noqa: BLE001 - die Verbindung ist ohnehin am Ende
logger.debug("Abmeldung von %s nicht mehr moeglich", self.host)
for closer in (kanal, self._transport):
try:
if closer is not None:
closer.close()
@@ -116,9 +157,26 @@ class SwitchCli:
# ------------------------------------------------------------------ #
def _read_until_prompt(self, *, timeout: int, initial: bool = False) -> str:
"""Liest bis zum Prompt. Ohne Prompt ist es ein Fehler, keine Antwort.
Bis 2.8.2 lief diese Schleife nach dem Zeitlimit einfach aus und gab
zurueck, was bis dahin gekommen war -- oft nichts. Der Aufrufer sah
eine leere Antwort und machte weiter: ``configure terminal`` ins
Nichts, ``no power-over-ethernet`` ins Nichts, und weil die
Zustandsabfrage dann "unknown" ergab und "unknown" als
"nicht widersprochen" durchging, meldete TESM am Ende **Erfolg** --
waehrend am Switch nichts geschaltet wurde. Genau das war im Betrieb
zu sehen: der Client blieb durchgehend erreichbar, das Protokoll sagte
"durchgefuehrt".
Ein Kommando ohne Prompt ist ein Kommando ohne Antwort. Das ist ein
Fehlschlag und wird als solcher gemeldet -- mit dem, was zuletzt
hereinkam, denn daran erkennt man, wo die Sitzung steht.
"""
assert self._channel is not None
deadline = time.monotonic() + timeout
collected = b""
prompt_gesehen = False
while time.monotonic() < deadline:
if self._channel.recv_ready():
chunk = self._channel.recv(READ_CHUNK)
@@ -130,6 +188,7 @@ class SwitchCli:
collected = PAGER_RE.sub(b"", collected)
continue
if PROMPT_RE.search(collected[-200:]):
prompt_gesehen = True
break
# Der Login-Banner mancher Geraete verlangt eine Bestaetigung.
if initial and collected.rstrip().endswith(b"continue"):
@@ -138,6 +197,19 @@ class SwitchCli:
time.sleep(0.05)
text = collected.decode("utf-8", errors="replace")
self.transcript.append(text)
if not prompt_gesehen:
letzte = " ".join(text.split())[-200:]
if initial:
raise SshError(
f"{self.host} hat sich nach der Anmeldung nicht gemeldet "
f"(kein Prompt innerhalb von {timeout}s)."
+ (f" Zuletzt kam: {letzte}" if letzte else " Es kam nichts.")
)
raise SshError(
f"{self.host} hat auf das Kommando nicht geantwortet "
f"(kein Prompt innerhalb von {timeout}s)."
+ (f" Zuletzt kam: {letzte}" if letzte else " Es kam nichts.")
)
return text
def send(
@@ -212,6 +284,17 @@ class SwitchCli:
)
result.transcript = self.full_transcript
return result
if before == "enabled" and off_state == "unknown":
# Vorher lesbar, nachher nicht: dann ist nicht die Firmware
# unbekannt, sondern die Sitzung aus dem Tritt. Das als Erfolg
# zu zaehlen war der Fehler, der einen Neustart meldete, den
# es nicht gab.
result.error = (
"Der Zustand des Ports ließ sich nach dem Abschalten nicht mehr "
"lesen – vorher ging es. Der Portzyklus ist damit nicht belegt."
)
result.transcript = self.full_transcript
return result
result.add(f"Zustand im ausgeschalteten Zustand: {off_state}")
self.set_poe(port, enabled=True)
@@ -224,6 +307,17 @@ class SwitchCli:
result.transcript = self.full_transcript
return result
if before == "unknown" and off_state == "unknown" and after == "unknown":
# Kein einziger Zustand lesbar: die Kommandos hat der Switch
# zwar mit einem Prompt bestaetigt (sonst waere die Sitzung
# oben abgebrochen), aber belegt ist der Zyklus nicht. Das
# gehoert in die Meldung -- "durchgefuehrt" waere zu viel
# gesagt.
result.add(
"Der Zustand war nicht lesbar – die Kommandos hat der Switch "
"angenommen, bestätigt ist der Zyklus damit nicht."
)
result.ok = True
except SshError as exc:
result.error = str(exc)
+125 -3
View File
@@ -62,6 +62,14 @@ CATEGORIES: tuple[Category, ...] = (
),
Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"),
Category("fileshare", "Freigabe-Zuordnungen", "Verzeichnisgruppe zu UNC-Pfad"),
Category(
"directory",
"Verzeichnisdienst",
"LDAP-Anbindung, Bind-Konto und Gruppen-Zuordnungen. Das Passwort des "
"Bind-Kontos nur mit \"Geheimnisse einschliessen\".",
admin_only=True,
sensitive=True,
),
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True),
Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True),
@@ -186,6 +194,49 @@ def build_export(
}
for row in Database.all(conn, "SELECT * FROM groups WHERE deleted_at IS NULL")
]
if "directory" in wanted:
# Die Anbindung an das Verzeichnis war das einzige Stueck Konfiguration,
# das man nach einer Wiederherstellung von Hand nachbauen musste --
# inklusive Bind-Passwort, das dabei jemand aus einem Kennwortspeicher
# holen muss. Es gehoert zur Anlage wie die Netzwerkeinstellung.
from tesm_core.auth import directory as verzeichnis
bind_benutzer, bind_passwort = verzeichnis.bind_credentials(conn)
gesetzt = {
str(row["key"]): str(row["value"])
for row in Database.all(conn, "SELECT key, value FROM settings")
}
payload["directory"] = {
# Nur die Schluessel der Anbindung, und nur die, die wirklich
# gesetzt sind: ein Vorgabewert in der Datei sieht aus wie eine
# Entscheidung und ueberschreibt beim Einlesen eine echte.
"settings": {
spec.key: gesetzt[spec.key] for spec in verzeichnis.SETTINGS
if spec.key in gesetzt
},
"bind_username": bind_benutzer,
# Ohne "Geheimnisse einschliessen" reist das Passwort nicht mit.
# Der Benutzername schon: er ist keine Geheimnis, und ohne ihn
# muesste man die halbe Anbindung doch wieder tippen.
"bind_password": bind_passwort if include_secrets else "",
# Die Zuordnung zeigt auf eine Gruppe **oder** auf das
# Administratorkennzeichen. Ueber eine Systemgrenze hinweg ist eine
# Gruppen-Id nichts wert -- der Name ist es.
"group_mappings": [
{
"ad_group_dn": str(row["ad_group_dn"]),
"ad_group_name": str(row["ad_group_name"]),
"group_name": str(row["group_name"] or ""),
"grants_admin": bool(row["grants_admin"]),
}
for row in Database.all(
conn,
"SELECT m.ad_group_dn, m.ad_group_name, m.grants_admin, g.name AS group_name "
"FROM ldap_group_mappings m "
"LEFT JOIN groups g ON g.id = m.app_group_id AND g.deleted_at IS NULL",
)
],
}
if "settings" in wanted:
payload["settings"] = {
str(row["key"]): str(row["value"])
@@ -534,19 +585,33 @@ def apply_import(
if "fileshare" in wanted:
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(
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO UPDATE SET "
"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("label") or ""),
str(entry.get("unc") or ""),
pfad,
stamp,
),
)
bump("fileshare", "updated")
bump("fileshare", "updated" if vorhanden else "created")
if "groups" in wanted:
for entry in payload.get("groups", []):
@@ -690,6 +755,63 @@ def apply_import(
)
bump("signatures", "created")
if "directory" in wanted:
from tesm_core.auth import directory as verzeichnis
abschnitt = payload.get("directory") or {}
bekannt = {spec.key for spec in verzeichnis.SETTINGS}
for key, value in (abschnitt.get("settings") or {}).items():
if str(key) not in bekannt:
# Ein fremder Schluessel in diesem Abschnitt gehoert nicht hier
# hin; die Kategorie "Einstellungen" nimmt alles andere.
bump("directory", "skipped")
continue
set_setting(str(key), value)
bump("directory", "updated")
benutzer = str(abschnitt.get("bind_username") or "").strip()
passwort = str(abschnitt.get("bind_password") or "")
if benutzer:
# Ohne Passwort bleibt ein vorhandenes stehen -- genau dafuer nimmt
# store_bind_credentials None. Eine Sicherung ohne Geheimnisse darf
# das Passwort nicht loeschen.
verzeichnis.store_bind_credentials(conn, benutzer, passwort or None)
bump("directory", "updated")
for eintrag in abschnitt.get("group_mappings") or []:
dn = str(eintrag.get("ad_group_dn") or "").strip()
if not dn:
bump("directory", "skipped")
continue
kennzeichen = bool(eintrag.get("grants_admin"))
gruppe = None
if not kennzeichen:
name = str(eintrag.get("group_name") or "").strip()
gruppe = Database.value(
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (name,)
)
if gruppe is None:
# Eine Zuordnung auf eine Gruppe, die es hier nicht gibt,
# waere eine Zuordnung ins Leere: beim naechsten Login
# bekaeme das Konto nichts, und niemand wuesste warum.
bump("directory", "skipped")
continue
conn.execute(
"INSERT INTO ldap_group_mappings "
"(ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn) DO UPDATE SET "
"ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id, "
"grants_admin=excluded.grants_admin",
(
dn,
str(eintrag.get("ad_group_name") or dn),
int(gruppe) if gruppe is not None else None,
int(kennzeichen),
stamp,
),
)
bump("directory", "created")
if "settings" in wanted:
for key, value in (payload.get("settings") or {}).items():
if str(key).startswith("license_"):
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-core"
version = "2.8.0"
version = "2.8.1"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = [
@@ -58,6 +58,30 @@ class SettingSpec:
return str(raw)
def serialize(self, value: Any) -> str:
"""Ein Wert als Text fuer die Datenbank.
Der Wert kann als Python-Wert kommen (aus einem Formular, aus dem
Code) -- oder als die Zeichenkette, die schon in der Datenbank stand.
Genau so liest eine **Sicherung** ihn wieder ein, und genau dort lag
ein stiller Fehler: ``serialize("0")`` ergab ``"1"``, weil jede nicht
leere Zeichenkette wahr ist. Aus jedem abgeschalteten Schalter wurde
beim Einspielen ein eingeschalteter -- auch aus
``ldap_allow_insecure``. Ein JSON-Feld wurde ein zweites Mal kodiert,
und aus der Navigationsreihenfolge damit eine Zeichenkette.
Deshalb: eine Zeichenkette erst lesen. Was hier herauskommt, muss
durch ``serialize`` unveraendert hindurchgehen -- sonst ueberlebt kein
Wert eine Sicherung.
"""
if isinstance(value, str):
if self.kind == "bool":
value = self.parse(value)
elif self.kind == "json":
try:
value = json.loads(value)
except ValueError:
# Kein JSON-Text, also ein Wert: dann wird er kodiert.
pass
if self.kind == "bool":
return "1" if value else "0"
if self.kind == "json":