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:
2026-09-02 22:21:42 +02:00
co-authored by Claude Opus 5
parent d8368fc200
commit cf16809ee2
6 changed files with 223 additions and 53 deletions
+77 -3
View File
@@ -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
+121 -46
View File
@@ -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>
@@ -563,7 +563,8 @@ function initAutoRefresh() {
// von innerHTML entsteht ein neues Element -- mit scrollTop 0. Genau
// deshalb sprang ein laufendes Auftragsprotokoll bei jeder
// Aktualisierung an den Anfang zurueck.
const scrollerOf = (el) => el.querySelector(".log-view, [data-scroll]") || el;
const scrollerOf = (el) =>
el.querySelector("[data-tail], .log-view, [data-scroll]") || el;
const before = scrollerOf(region);
const position = before.scrollTop;
// "Am Ende" grosszuegig auslegen: wer mitliest, soll mitgezogen
@@ -814,6 +815,25 @@ function initTileFilters() {
/* -- Start ---------------------------------------------------------------- */
/**
* Protokollansichten stehen am Ende, nicht am Anfang.
*
* Bei einem Protokoll ist die neueste Zeile die interessante, und die steht
* unten. Ohne diesen Anfangssprung blieb die Ansicht oben stehen -- und weil
* die Aktualisierung nur mitzieht, wenn man ohnehin schon am Ende ist, zog
* sie danach nie mit. Es sah aus, als waere der Autoscroll kaputt; in
* Wirklichkeit stand man nur nie an der Stelle, an der er greift.
*
* Ausdruecklich per ``data-tail`` markiert: eine Konfigurationsvorschau
* (nginx, Kea) und der Offline-Code sind auch <pre class="log-view">, sollen
* aber oben beginnen.
*/
function initLogTails(root = document) {
root.querySelectorAll("[data-tail]").forEach((view) => {
view.scrollTop = view.scrollHeight;
});
}
function boot() {
initTheme();
initSidebar();
@@ -830,6 +850,7 @@ function boot() {
initPalette();
initSubmitGuards();
initTileFilters();
initLogTails();
unsavedGuard.init();
}
@@ -1,5 +1,5 @@
{% if lines %}
<pre class="log-view">{% for line in lines %}{{ line }}
<pre class="log-view" data-tail>{% for line in lines %}{{ line }}
{% endfor %}</pre>
{% else %}
<div class="empty"><div class="empty__title">Keine Zeilen</div>