diff --git a/apps/tesm/src/tesm/services/fileshare.py b/apps/tesm/src/tesm/services/fileshare.py index 9232dc4..758db02 100644 --- a/apps/tesm/src/tesm/services/fileshare.py +++ b/apps/tesm/src/tesm/services/fileshare.py @@ -27,6 +27,7 @@ import os import re import secrets import shutil +import socket import sqlite3 from dataclasses import dataclass from datetime import datetime, timedelta, timezone @@ -114,6 +115,73 @@ def validate_unc(value: str) -> str: # --------------------------------------------------------------------------- # +def _resolvable(host: str) -> bool: + """Loest dieser Name auf diesem Server auf?""" + try: + socket.getaddrinfo(host, None) + except (socket.gaierror, UnicodeError, OSError): + return False + return True + + +def search_suffixes(conn: sqlite3.Connection) -> list[str]: + """Domaenen, mit denen ein kurzer Hostname vervollstaendigt werden kann. + + Ein Windows-Client loest "S2025" ueber seine Suchdomaene auf. Dieser Server + hat oft keine (``search .`` in resolv.conf), und dann scheitert die + Einbindung mit "could not resolve address for S2025" -- obwohl der Name im + Netz voellig richtig ist. Die AD-Domaene ist aber bekannt, also wird sie + hier zum Vervollstaendigen benutzt. + """ + kandidaten: list[str] = [] + einstellungen = core().settings + basis = str(einstellungen.get(conn, "ldap_base_dn") or "").strip() + if basis: + # DC=ad,DC=eertmoed,DC=net -> ad.eertmoed.net + teile = [ + stueck.split("=", 1)[1] + for stueck in basis.split(",") + if stueck.strip().lower().startswith("dc=") + ] + if teile: + kandidaten.append(".".join(teile)) + rechner = str(einstellungen.get(conn, "ldap_host") or "").strip() + if rechner and "." in rechner: + kandidaten.append(rechner) + # Auch die Elterndomaene: ist ldap_host ein DC-Name, liegt die Domaene + # eine Ebene darueber. + kandidaten.append(rechner.split(".", 1)[1]) + # Reihenfolge erhalten, Doppelte entfernen. + gesehen: set[str] = set() + return [k for k in kandidaten if k and not (k in gesehen or gesehen.add(k))] + + +def resolve_unc_host(unc: str, suffixes: Iterable[str]) -> tuple[str, str]: + """Ersetzt den Hostnamen einer UNC durch einen auflösbaren. + + Rueckgabe: ``(unc, hinweis)``. Der Hinweis ist leer, wenn nichts geaendert + wurde; laesst sich der Name gar nicht aufloesen, bleibt die UNC unveraendert + und der Hinweis nennt das Problem. + """ + teile = unc.lstrip(chr(92)).split(chr(92), 1) + if not teile or not teile[0]: + return unc, "" + host = teile[0] + rest = teile[1] if len(teile) > 1 else "" + if _resolvable(host): + return unc, "" + for suffix in suffixes: + kandidat = f"{host}.{suffix.strip('.')}" + if _resolvable(kandidat): + neu = chr(92) * 2 + kandidat + (chr(92) + rest if rest else "") + return neu, f"Name {host} ueber {kandidat} aufgeloest" + return unc, ( + f"Der Name {host} ist auf diesem Server nicht auflösbar. Bitte den " + "vollqualifizierten Namen oder die IP-Adresse eintragen -- oder unter " + "Netzwerk eine Suchdomäne setzen." + ) + + def list_mappings(conn: sqlite3.Connection) -> list[dict[str, Any]]: return Database.all( conn, "SELECT * FROM fileshare_mappings ORDER BY label COLLATE NOCASE" @@ -259,12 +327,18 @@ def mount_for_login( root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares")) messages: list[str] = [] + suffixes = search_suffixes(conn) for share in shares: token = secrets.token_urlsafe(16) target = root / token - result = sysops.mount_cifs( - str(share["unc"]), str(target), username, password, domain=domain - ) + # Kurze Hostnamen vervollstaendigen, bevor mount daran scheitert. + unc, hinweis = resolve_unc_host(str(share["unc"]), suffixes) + if unc == str(share["unc"]) and hinweis: + messages.append(f"{share['label']}: {hinweis}") + continue + if hinweis: + logger.info("%s: %s", share["label"], hinweis) + result = sysops.mount_cifs(unc, str(target), username, password, domain=domain) if not result.ok: messages.append(f"{share['label']}: Einbindung fehlgeschlagen ({result.message})") continue diff --git a/apps/tesm/src/tesm/services/windowsops.py b/apps/tesm/src/tesm/services/windowsops.py index 413238f..ef874dd 100644 --- a/apps/tesm/src/tesm/services/windowsops.py +++ b/apps/tesm/src/tesm/services/windowsops.py @@ -102,43 +102,54 @@ def hint_for(output: str) -> str: return "" -def reboot( - host: str, +def split_account(username: str, domain: str = "") -> tuple[str, str]: + """Trennt ``DOMAENE\\benutzer`` in Benutzer und Domaene. + + Toleranter Split: mehrfache Backslashes werden zusammengefasst, leere Teile + verworfen. Das ist kein Selbstzweck -- der Hinweistext dieser Datei empfahl + selbst einmal ``DOMAENE\\\\benutzer`` mit zwei Backslashes, und genau so + wurde es dann eingetragen. Ein einfacher ``split()`` schickte daraufhin + ``\\benutzer`` an Windows, und die Anmeldung scheiterte mit LOGON_FAILURE -- + bei formal richtigen Zugangsdaten. + + Rueckgabe: ``(benutzer, domaene)``. + """ + if "\\" in username and not domain: + teile = [teil.strip() for teil in username.split("\\") if teil.strip()] + if len(teile) >= 2: + return teile[-1], teile[0] + if teile: + return teile[0], domain + return username, domain + + +def _net_with_credentials( + args: list[str], + *, username: str, password: str, - *, - domain: str = "", - comment: str = "Neustart durch TESM-Wartung", - delay: int = 5, - timeout: int = DEFAULT_TIMEOUT, -) -> RemoteResult: - """Loest einen Neustart auf einem Windows-Geraet aus.""" + domain: str, + timeout: int, +) -> RemoteResult | tuple[int, str]: + """Ruft ``net`` mit einer 0600-Anmeldedatei auf und raeumt sie sicher weg. + + Gemeinsam fuer Neustart und Pruefung -- die Datei traegt ein + Klartextpasswort, und diese Behandlung will man genau einmal haben. + Rueckgabe: ``(exitcode, ausgabe)`` oder ein fertiges ``RemoteResult`` bei + einem Fehler noch vor dem Aufruf. + """ binary = shutil.which("net") if binary is None: return RemoteResult( False, "", f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' laesst sich " - "ein Windows-Geraet von hier nicht neu starten.", + "ein Windows-Geraet von hier nicht ansprechen.", ) if not username: - return RemoteResult(False, "", "Fuer das Geraet sind keine Zugangsdaten hinterlegt.") - - # Ein Benutzer der Form DOMAENE\benutzer bringt seine Domaene selbst mit. - # - # Toleranter Split: mehrfache Backslashes werden zusammengefasst, leere - # Teile verworfen. Das ist kein Selbstzweck -- der Hinweistext hier oben - # empfahl selbst einmal "DOMAENE\\benutzer" mit zwei Backslashes, und genau - # so wurde es dann eingetragen. Ein einfacher split() schickte daraufhin - # "\benutzer" an Windows, und die Anmeldung scheiterte mit LOGON_FAILURE -- - # bei formal richtigen Zugangsdaten. - if "\\" in username and not domain: - teile = [teil.strip() for teil in username.split("\\") if teil.strip()] - if len(teile) >= 2: - domain, username = teile[0], teile[-1] - elif teile: - username = teile[0] + return RemoteResult(False, "", "Es sind keine Zugangsdaten hinterlegt.") + username, domain = split_account(username, domain) handle, path = tempfile.mkstemp(prefix="tesm-winauth-") try: # mkstemp legt die Datei auf POSIX bereits mit 0600 an. Hier trotzdem @@ -153,21 +164,7 @@ def reboot( if domain: stream.write(f"domain={domain}\n") - command = [ - binary, - "rpc", - "shutdown", - "-r", # neu starten statt herunterfahren - "-f", # laufende Anwendungen nicht blockieren lassen - "-t", - str(max(0, int(delay))), - "-C", - comment, - "-I", - host, - "--authentication-file", - path, - ] + command = [binary, *args, "--authentication-file", path] try: completed = subprocess.run( # noqa: S603 - feste Argumentliste, keine Shell command, capture_output=True, text=True, timeout=timeout, check=False @@ -176,11 +173,7 @@ def reboot( return RemoteResult(False, "", f"Zeitlimit von {timeout}s ueberschritten.") except OSError as exc: return RemoteResult(False, "", str(exc)) - - output = (completed.stdout + completed.stderr).strip() - if completed.returncode == 0: - return RemoteResult(True, output or "Neustart ausgeloest.") - return RemoteResult(False, output, hint_for(output)) + return completed.returncode, (completed.stdout + completed.stderr).strip() finally: # Ueberschreiben, dann loeschen -- die Datei enthaelt ein Klartextpasswort. try: @@ -196,3 +189,85 @@ def reboot( os.unlink(path) except OSError: # pragma: no cover - defensiv pass + + +def check( + host: str, + username: str, + password: str, + *, + domain: str = "", + timeout: int = DEFAULT_TIMEOUT, +) -> RemoteResult: + """Prueft Zugangsdaten gegen ein Windows-Geraet -- **ohne** etwas zu aendern. + + ``net rpc info`` ist rein lesend. Der Sinn: ob Zugangsdaten stimmen, soll + man erfahren, ohne einen Neustart auszuloesen und hinterher im Protokoll zu + raten. Unterschieden wird ausdruecklich zwischen "Passwort falsch" und + "Anmeldung in Ordnung, aber keine Administratorrechte" -- das sind zwei + voellig verschiedene Aufgaben fuer den, der es beheben muss. + + Ein Fehlversuch zaehlt fuer die Sperrrichtlinie der Domaene mit. Deshalb + genau ein Aufruf je Klick und kein automatisches Wiederholen. + """ + ergebnis = _net_with_credentials( + ["rpc", "info", "-I", host], + username=username, + password=password, + domain=domain, + timeout=timeout, + ) + if isinstance(ergebnis, RemoteResult): + return ergebnis + code, output = ergebnis + + if code == 0: + return RemoteResult( + True, output, "Anmeldung erfolgreich, Administratorrechte vorhanden." + ) + if "ACCESS_DENIED" in output: + # Wichtig: die Anmeldung *hat* funktioniert. Nur die Rechte fehlen. + return RemoteResult( + False, + output, + "Anmeldung erfolgreich, aber das Konto hat auf diesem Geraet keine " + "Administratorrechte. Fuer einen Neustart ueber RPC braucht es sie.", + ) + return RemoteResult(False, output, hint_for(output) or "Pruefung fehlgeschlagen.") + + +def reboot( + host: str, + username: str, + password: str, + *, + domain: str = "", + comment: str = "Neustart durch TESM-Wartung", + delay: int = 5, + timeout: int = DEFAULT_TIMEOUT, +) -> RemoteResult: + """Loest einen Neustart auf einem Windows-Geraet aus.""" + ergebnis = _net_with_credentials( + [ + "rpc", + "shutdown", + "-r", # neu starten statt herunterfahren + "-f", # laufende Anwendungen nicht blockieren lassen + "-t", + str(max(0, int(delay))), + "-C", + comment, + "-I", + host, + ], + username=username, + password=password, + domain=domain, + timeout=timeout, + ) + if isinstance(ergebnis, RemoteResult): + return ergebnis + code, output = ergebnis + if code == 0: + return RemoteResult(True, output or "Neustart ausgeloest.") + return RemoteResult(False, output, hint_for(output)) diff --git a/apps/tesm/src/tesm/templates/tesm/_maintenance_output.html b/apps/tesm/src/tesm/templates/tesm/_maintenance_output.html index 5cf4aea..914ea93 100644 --- a/apps/tesm/src/tesm/templates/tesm/_maintenance_output.html +++ b/apps/tesm/src/tesm/templates/tesm/_maintenance_output.html @@ -1 +1 @@ -
{{ job.output or 'Noch keine Ausgabe.' }}
+
{{ job.output or 'Noch keine Ausgabe.' }}
diff --git a/apps/tesm/src/tesm/templates/tesm/console.html b/apps/tesm/src/tesm/templates/tesm/console.html index e29a5c4..760274b 100644 --- a/apps/tesm/src/tesm/templates/tesm/console.html +++ b/apps/tesm/src/tesm/templates/tesm/console.html @@ -49,7 +49,7 @@ vermerkt. Bei Untaetigkeit trennt die Verbindung nach ' ~ idle_timeout ~ ' Sekun {{ icon('copy', size=14) }} Mitschrift kopieren -

+