diff --git a/apps/tesm/src/tesm/__init__.py b/apps/tesm/src/tesm/__init__.py index 53cedb7..51c0693 100644 --- a/apps/tesm/src/tesm/__init__.py +++ b/apps/tesm/src/tesm/__init__.py @@ -287,6 +287,39 @@ def _startup_tasks(app: Flask) -> None: extension.license.start_heartbeat(hostname=socket.gethostname()) + _start_fileshare_sweep(app) + + +#: Wie oft nach abgelaufenen Einbindungen gesehen wird. +FILESHARE_SWEEP_SECONDS = 300 + + +def _start_fileshare_sweep(app: Flask) -> None: + """Loest abgelaufene Freigabe-Einbindungen -- im Webprozess. + + Muss hier laufen und nicht im Ueberwachungsdienst: eingebunden wird im + Mount-Namensraum dieses Dienstes, und ein anderer Dienst sieht diese + Einbindungen nicht einmal. Ein ``umount`` von dort waere ein Aufruf ins + Leere -- die Datenbankzeile waere als geloest markiert, die Freigabe aber + weiter eingebunden. + """ + import threading + + from .services import fileshare + + def schleife() -> None: + while True: + time.sleep(FILESHARE_SWEEP_SECONDS) + try: + with app.app_context(): + geloest = fileshare.sweep_expired(core(app).database) + if geloest: + logger.info("%s abgelaufene Freigabe-Einbindungen geloest", geloest) + except Exception: # noqa: BLE001 - der Webprozess darf nie sterben + logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen") + + threading.Thread(target=schleife, name="fileshare-sweep", daemon=True).start() + # Fuer ``gunicorn tesm:app`` def _lazy_app() -> Flask: # pragma: no cover - nur vom WSGI-Server benutzt diff --git a/apps/tesm/src/tesm/blueprints/devices.py b/apps/tesm/src/tesm/blueprints/devices.py index 8463472..86a57b5 100644 --- a/apps/tesm/src/tesm/blueprints/devices.py +++ b/apps/tesm/src/tesm/blueprints/devices.py @@ -307,6 +307,10 @@ def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # typ password=password, wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300), actor=current_user.username, + # Der Weg ist oben schon entschieden (restart.resolve). Ihn hier + # mitzugeben ist der Unterschied zwischen "Windows-Geraet ueber RPC" und + # "Windows-Geraet ueber SSH auf Port 22, bis das Zeitlimit greift". + method=method.key, ) flash( f"{method.label} fuer {device['name']} gestartet. Der Verlauf steht unter Wartung.", diff --git a/apps/tesm/src/tesm/blueprints/maintenance.py b/apps/tesm/src/tesm/blueprints/maintenance.py index e9f2fb2..b7be785 100644 --- a/apps/tesm/src/tesm/blueprints/maintenance.py +++ b/apps/tesm/src/tesm/blueprints/maintenance.py @@ -255,6 +255,15 @@ def _start(kind: str) -> None: password=password, wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300), actor=current_user.username, + # Hier steht in ``category`` die Kategorie der *Zugangsdaten* + # (siehe _query_devices) -- daraus folgt der Weg. Uebergeben + # statt in run_reboot erraten: dort bedeutet derselbe + # Schluessel je Herkunft etwas anderes. + method=( + service.METHOD_RPC + if str(device["category"]) == inventory.NON_SSH_CATEGORY + else service.METHOD_SSH + ), ) started += 1 diff --git a/apps/tesm/src/tesm/services/maintenance.py b/apps/tesm/src/tesm/services/maintenance.py index c38d5a5..6e4469a 100644 --- a/apps/tesm/src/tesm/services/maintenance.py +++ b/apps/tesm/src/tesm/services/maintenance.py @@ -500,6 +500,7 @@ def run_reboot( password: str, wait_seconds: int, actor: str = "system", + method: str = METHOD_SSH, ) -> None: """Loest einen Neustart aus und wartet auf die Rueckkehr. @@ -518,7 +519,15 @@ def run_reboot( return int((time.monotonic() - started) * 1000) host = str(device["ip"]) - is_windows = str(device.get("category") or "") == WINDOWS_CATEGORY + # Der Weg wird **uebergeben**, nicht aus dem Geraet abgeleitet. Vorher stand + # hier ``device["category"]`` -- und dieser Schluessel bedeutet je Herkunft + # etwas anderes: die Wartungsabfrage legt die Kategorie der *Zugangsdaten* + # darunter, ``inventory.get_device`` die des *Geraets*. Ueber die + # Statusuebersicht kam damit ein Windows-Geraet mit leerer Geraetekategorie + # an, wurde fuer Linux gehalten und ueber SSH auf Port 22 angesprochen -- + # bis zum Zeitlimit. Die Entscheidung faellt jetzt einmal beim Aufrufer + # (``restart.resolve``) und wird hier nur noch befolgt. + is_windows = method == METHOD_RPC # Woran erkennt man, dass das Geraet zurueck ist? Am selben Dienst, ueber # den der Neustart lief: SSH bei Linux, SMB/RPC bei Windows. probe_port = WINDOWS_PROBE_PORT if is_windows else int(device.get("ssh_port") or 22) @@ -566,6 +575,7 @@ def run_reboot( actor=actor, duration_ms=elapsed(), detail=str(exc), + method=method, ) return @@ -607,6 +617,7 @@ def run_reboot( actor=actor, duration_ms=elapsed(), detail=problem, + method=method, ) return @@ -646,6 +657,7 @@ def run_reboot( actor=actor, duration_ms=elapsed(), detail="Neustart ueber SSH, Geraet ist zurueckgekehrt.", + method=method, ) return time.sleep(REBOOT_POLL_SECONDS) @@ -669,7 +681,8 @@ def run_reboot( f"Neustart ausgeloest, aber {host}:{ssh_port} hat innerhalb von " f"{wait_seconds}s nicht wieder geantwortet." ), - ) + method=method, + ) def _log_restart( diff --git a/apps/tesm/src/tesm/templates/tesm/logs_restarts.html b/apps/tesm/src/tesm/templates/tesm/logs_restarts.html index 9e8a396..c38330d 100644 --- a/apps/tesm/src/tesm/templates/tesm/logs_restarts.html +++ b/apps/tesm/src/tesm/templates/tesm/logs_restarts.html @@ -36,8 +36,13 @@ Der Vorgaenger protokollierte Neustarts ausdruecklich nicht.', 'info') }} {{ row.ts | dt }} {{ row.device_name or '–' | safe }} + {# Kein if/else mit zwei Zweigen: es gibt drei Wege, und alles + ausser 'ssh' als "PoE-Port" anzuzeigen hat RPC-Neustarts + jahrelang als PoE ausgegeben. #} {% if row.method == 'ssh' %}{{ pill('SSH', 'info') }} - {% else %}{{ pill('PoE-Port', '') }}{% endif %} + {% elif row.method == 'rpc' %}{{ pill('RPC', 'accent') }} + {% elif row.method == 'poe' %}{{ pill('PoE-Port', '') }} + {% else %}{{ pill(row.method or 'unbekannt', 'warning') }}{% endif %} {{ row.trigger }} {{ row.actor }} diff --git a/apps/tesm/src/tesm/worker.py b/apps/tesm/src/tesm/worker.py index 98ab2da..afc0142 100644 --- a/apps/tesm/src/tesm/worker.py +++ b/apps/tesm/src/tesm/worker.py @@ -22,12 +22,11 @@ from types import FrameType from tesm_core.extension import core from . import create_app -from .services import fileshare, inventory, monitor +from .services import inventory, monitor logger = logging.getLogger("tesm.monitor") MIN_INTERVAL = 30 -FILESHARE_SWEEP_EVERY = 30 * 60 class MonitorDaemon: @@ -35,7 +34,6 @@ class MonitorDaemon: self.app = create_app() self.extension = core(self.app) self.running = True - self._last_fileshare_sweep = 0.0 def stop(self, signum: int, _frame: FrameType | None) -> None: logger.info("Signal %s empfangen -- beende nach dem laufenden Durchlauf.", signum) @@ -100,15 +98,12 @@ class MonitorDaemon: logger.exception("Durchlauf fehlgeschlagen") live.error("monitor", "Durchlauf fehlgeschlagen -- siehe app.log") - if time.monotonic() - self._last_fileshare_sweep > FILESHARE_SWEEP_EVERY: - self._last_fileshare_sweep = time.monotonic() - try: - with self.app.app_context(): - released = fileshare.sweep_expired(self.extension.database) - if released: - live.info("fileshare", f"{released} abgelaufene Einbindungen geloest") - except Exception: # noqa: BLE001 - logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen") + # Das Aufraeumen abgelaufener Freigabe-Einbindungen laeuft **nicht** + # hier, sondern im Webprozess: eingebunden wird in dessen + # Mount-Namensraum, und dieser Dienst sieht diese Einbindungen nicht + # einmal. Ein umount von hier waere ein Aufruf ins Leere -- die + # Datenbankzeile als geloest markiert, die Freigabe aber weiter + # eingebunden. Siehe tesm/__init__.py::_start_fileshare_sweep. elapsed = time.monotonic() - started remaining = max(1.0, interval - elapsed) diff --git a/deploy/systemd/tesm.service b/deploy/systemd/tesm.service index 51cb663..a3b557e 100644 --- a/deploy/systemd/tesm.service +++ b/deploy/systemd/tesm.service @@ -59,7 +59,20 @@ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK LockPersonality=yes MemoryDenyWriteExecute=no SystemCallArchitectures=native -SystemCallFilter=@system-service +# "@mount" zusaetzlich zu @system-service: die Dateifreigaben werden ueber +# mount.cifs eingebunden, und @system-service enthaelt mount/umount2 nicht. +# Ohne diese Ergaenzung scheitert jede Einbindung mit "mount error(1): +# Operation not permitted" -- gemessen, und zwar unabhaengig von Rechten, +# Zugangsdaten und Mount-Optionen: aus einer normalen Root-Shell gelingt +# derselbe Aufruf sofort. Der Umweg ueber nsenter in den Namensraum des Wirts +# ist keine Alternative, weil "RestrictNamespaces=yes" setns verbietet. +# +# Eingebunden wird damit im Mount-Namensraum *dieses* Dienstes. Das ist genau +# richtig: nur er liest die Dateien, andere Prozesse haben dort nichts zu +# suchen, und mit dem Dienst verschwinden die Einbindungen wieder. Die +# Aufraeumaufgabe fuer abgelaufene Einbindungen laeuft deshalb im Webprozess -- +# der Ueberwachungsdienst koennte sie nicht einmal sehen. +SystemCallFilter=@system-service @mount SystemCallErrorNumber=EPERM # Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht # CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt, diff --git a/deploy/tesm-helper b/deploy/tesm-helper index cd7dfc1..70aee1d 100644 --- a/deploy/tesm-helper +++ b/deploy/tesm-helper @@ -258,19 +258,17 @@ case "$verb" in # Kommandozeile (Prozessliste) und nie ueber die Umgebung # (/proc//environ). read_stdin_limited > "$creds" - # Der aufrufende Dienst laeuft in einem eigenen Mount-Namensraum - # (ProtectSystem, PrivateTmp, ReadWritePaths). Ein Einbinden *darin* - # scheitert mit "mount error(1): Operation not permitted" -- gemessen: mit - # denselben Optionen und denselben Zugangsdaten gelingt es aus einer - # normalen Root-Shell sofort. Deshalb wird im Namensraum des Wirts - # eingebunden; von dort wird der Mount in den Namensraum des Dienstes - # weitergegeben (er haengt als "slave" darunter), die Anwendung sieht ihn - # also. Die Zugangsdatei unter /run ist ueber die Grenze lesbar, /run ist - # nicht privatisiert. - im_wirt=(nsenter --mount=/proc/1/ns/mnt --) - command -v nsenter >/dev/null 2>&1 || im_wirt=() - "${im_wirt[@]}" mkdir -p "$target" - if "${im_wirt[@]}" mount -t cifs "$share" "$target" \ + # Eingebunden wird im Mount-Namensraum des aufrufenden Dienstes -- absichtlich: + # nur dieser Dienst liest die Dateien, und mit ihm verschwinden die + # Einbindungen wieder. Damit der Syscall durchkommt, braucht die Unit + # "@mount" im SystemCallFilter. Ohne das scheitert es mit + # "mount error(1): Operation not permitted"; gemessen war der Syscall-Filter + # die Ursache, nicht der Namensraum. Ein Umweg ueber nsenter in den + # Namensraum des Wirts ist keine Option -- "RestrictNamespaces=yes" + # verbietet setns, und das Recht dafuer wieder aufzugeben waere teurer als + # der Gewinn. + mkdir -p "$target" + if mount -t cifs "$share" "$target" \ -o "credentials=$creds,uid=$(id -u tesm 2>/dev/null || echo 0),gid=$(id -g tesm 2>/dev/null || echo 0),file_mode=0640,dir_mode=0750,vers=3.1.1,seal" then shred -u "$creds" 2>/dev/null || rm -f "$creds" @@ -282,13 +280,12 @@ case "$verb" in umount) check_mount_target "${1:?Ziel fehlt}" - # Gegenstueck zu mount-cifs: der Mount liegt im Namensraum des Wirts, also - # muss er auch dort geloest werden. Ein umount im Dienst-Namensraum wuerde - # ihn nur dort ausblenden und die Freigabe bliebe eingebunden. - im_wirt=(nsenter --mount=/proc/1/ns/mnt --) - command -v nsenter >/dev/null 2>&1 || im_wirt=() - "${im_wirt[@]}" umount -l "$1" || true - "${im_wirt[@]}" rmdir "$1" 2>/dev/null || true + # Gegenstueck zu mount-cifs, und deshalb im selben Namensraum: loesen kann + # nur, wer die Einbindung auch sieht. Ein Aufruf aus einem anderen Dienst + # greift ins Leere -- genau deshalb laeuft die Aufraeumaufgabe fuer + # abgelaufene Einbindungen im Webprozess und nicht im Ueberwachungsdienst. + umount -l "$1" || true + rmdir "$1" 2>/dev/null || true ;; apt-install) @@ -296,9 +293,27 @@ case "$verb" in for package in "$@"; do check_in_list "$package" "${PACKAGES_ALLOWED[@]}" || fail "Paket nicht erlaubt: $package" done + # apt laeuft ausserhalb des Sandkastens des aufrufenden Dienstes. Der wird + # sonst an dpkg und dessen Maintainer-Skripte weitervererbt, und dort + # scheitert etwas voellig Beilaeufiges: mit "ProtectKernelLogs=yes" gibt + # "dmesg" ein EPERM zurueck, ein Postinst-Skript bricht daran ab, und dpkg + # endet mit Code 1 -- die Installation schlaegt fehl, obwohl mit dem Paket + # alles in Ordnung ist. Gemessen und mit systemd-run reproduzierbar + # behoben: PID 1 startet den Prozess, also ohne unsere Haertung. export DEBIAN_FRONTEND=noninteractive - apt-get update -qq - apt-get install -y -qq "$@" + if command -v systemd-run >/dev/null 2>&1 && [[ -S /run/dbus/system_bus_socket ]]; then + # Zwei getrennte Aufrufe, damit keine Shell dazwischen steht: die + # Paketnamen gehen als eigene Argumente an apt-get, nicht durch eine + # Kommandozeile, die sie erst wieder zerlegen muesste. + ausserhalb=(systemd-run --wait --pipe --collect --quiet + --setenv=DEBIAN_FRONTEND=noninteractive) + "${ausserhalb[@]}" apt-get update -qq + "${ausserhalb[@]}" apt-get install -y -qq "$@" + else + # Kein systemd (Container, Entwicklungsrechner): direkt, wie bisher. + apt-get update -qq + apt-get install -y -qq "$@" + fi ;; certbot-issue) diff --git a/tesm-v2.0.0.tar.gz b/tesm-v2.0.1.tar.gz similarity index 51% rename from tesm-v2.0.0.tar.gz rename to tesm-v2.0.1.tar.gz index 6f10933..d140710 100644 Binary files a/tesm-v2.0.0.tar.gz and b/tesm-v2.0.1.tar.gz differ