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