Freigaben einbinden, apt-Installation und RPC-Neustart korrigiert

Drei Fehler, alle drei gemessen statt vermutet.

1. Freigaben wurden nie eingebunden ("mount error(1): Operation not
   permitted"). Ursache war NICHT der Mount-Namensraum, sondern
   SystemCallFilter=@system-service: darin fehlen mount/umount2, und
   SystemCallErrorNumber=EPERM macht daraus genau diese Meldung. Nachgewiesen
   in einem nachgebauten Sandkasten: mit "@mount" gelingt die Einbindung,
   ohne nicht. Die Unit von TESM erlaubt jetzt "@system-service @mount"; alles
   andere an der Haertung bleibt. Der zuvor eingebaute Umweg ueber nsenter ist
   zurueckgenommen -- RestrictNamespaces=yes verbietet setns, er konnte nie
   funktionieren.

   Eingebunden wird damit im Namensraum des Webprozesses. Deshalb ist die
   Aufraeumaufgabe fuer abgelaufene Einbindungen aus dem Ueberwachungsdienst in
   den Webprozess gewandert: ein anderer Dienst sieht diese Einbindungen nicht
   und haette Datenbankzeilen als geloest markiert, waehrend die Freigabe
   eingebunden blieb.

2. apt-Installationen aus der Oberflaeche scheiterten mit "dpkg returned an
   error code (1)". Der Sandkasten wird an dpkg und dessen Maintainer-Skripte
   weitervererbt; mit ProtectKernelLogs=yes liefert "dmesg" ein EPERM und ein
   Postinst-Skript bricht daran ab. apt laeuft jetzt ueber systemd-run, also
   von PID 1 gestartet und damit ohne unsere Haertung.

3. Der RPC-Neustart loeste SSH aus. run_reboot entschied den Weg anhand von
   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 ein
   Windows-Geraet mit leerer Geraetekategorie an, wurde fuer Linux gehalten und
   ueber SSH auf Port 22 angesprochen, bis das Zeitlimit griff. Der Weg wird
   jetzt uebergeben -- entschieden wird er ohnehin schon vorher in
   restart.resolve. Ausserdem zeigte die Neustart-Historie alles ausser "ssh"
   als "PoE-Port"; RPC hat jetzt einen eigenen Eintrag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 21:49:40 +02:00
co-authored by Claude Opus 5
parent 5bb0809009
commit 4065b3586b
9 changed files with 125 additions and 38 deletions
+33
View File
@@ -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
+4
View File
@@ -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.",
@@ -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
+15 -2
View File
@@ -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(
@@ -36,8 +36,13 @@ Der Vorgaenger protokollierte Neustarts ausdruecklich nicht.', 'info') }}
<td class="fs-sm nowrap" data-sort-value="{{ row.ts }}">{{ row.ts | dt }}</td>
<td class="fw-600 fs-sm">{{ row.device_name or '&ndash;' | safe }}</td>
<td class="fs-sm">
{# 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 %}
</td>
<td class="fs-sm">{{ row.trigger }}</td>
<td class="fs-sm">{{ row.actor }}</td>
+7 -12
View File
@@ -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)