Kurze Hostnamen in Freigaben aufloesen, Protokolle am Ende oeffnen
Freigaben mit kurzem Hostnamen (\S2025\Share) scheiterten mit "could not resolve address for S2025". Ursache: dieser Server hat keine Suchdomaene (search . in resolv.conf), ein Windows-Client hat eine. Der Name ist also richtig, nur hier nicht auflösbar. Die AD-Domaene ist aus den Einstellungen des Verzeichnisdienstes bekannt und wird jetzt zum Vervollstaendigen benutzt; laesst sich der Name gar nicht aufloesen, sagt die Meldung was zu tun ist statt "Unknown error". Auf POETEST gegen echtes DNS geprueft: \S2025\LDAP_Share -> \S2025.ad.eertmoed.net\LDAP_Share, und dieser Pfad bindet ein. Protokollansichten standen beim Oeffnen oben. Die neueste Zeile steht aber unten, und weil die Aktualisierung nur mitzieht, wenn man ohnehin am Ende ist, zog sie danach nie mit -- es sah aus wie ein kaputter Autoscroll, war aber nur die falsche Anfangsposition. Die Protokollansichten sind jetzt per data-tail markiert und springen beim Laden nach unten. Konfigurationsvorschauen (nginx, Kea) und der Offline-Code sind bewusst nicht markiert: die beginnen oben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1 +1 @@
|
||||
<pre class="log-view">{{ job.output or 'Noch keine Ausgabe.' }}</pre>
|
||||
<pre class="log-view" data-tail>{{ job.output or 'Noch keine Ausgabe.' }}</pre>
|
||||
|
||||
@@ -49,7 +49,7 @@ vermerkt. Bei Untaetigkeit trennt die Verbindung nach ' ~ idle_timeout ~ ' Sekun
|
||||
{{ icon('copy', size=14) }} Mitschrift kopieren
|
||||
</button>
|
||||
</div>
|
||||
<pre class="log-view" id="console-output" style="max-height:56dvh"></pre>
|
||||
<pre class="log-view" data-tail id="console-output" style="max-height:56dvh"></pre>
|
||||
<div class="card__footer" style="justify-content:stretch;gap:var(--sp-2)">
|
||||
<select class="select" id="console-preset" style="max-width:280px">
|
||||
<option value="">Kommando aus der Liste ...</option>
|
||||
|
||||
Reference in New Issue
Block a user