feat: Störungswache (pipe.monitor) + Verschlüsselung-at-rest dokumentiert

Dienste-Ampel (Telefon, Verarbeitung, Spracherkennung, Nextcloud) alarmierte
bisher nur im offenen Leitstand. pipe/monitor.py laeuft jetzt als eigener
Dauerprozess, prueft dieselbe Ampel (neu ausgelagert nach pipe/dienste.py)
periodisch und alarmiert bei Stoerung lokal per macOS-Benachrichtigung + Ton
(pipe/alarm.py) - unabhaengig davon, ob pipe.watch oder pipe.server laufen.
Wird ab jetzt von telefon/starten.sh mitgestartet.

Verschluesselung at rest: keine App-eigene Verschluesselung eingebaut,
stattdessen im README als Betreiber-Pflicht dokumentiert (FileVault aktivieren)
- App verlaesst sich auf Festplattenverschluesselung durch das Betriebssystem.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbdWYnDtyMgxsq4eN1WnrQ
This commit is contained in:
Jeuner 2026-08-28 16:47:45 +02:00
parent be0252c907
commit 6e5fe59c53
8 changed files with 266 additions and 90 deletions

27
pipe/alarm.py Normal file
View file

@ -0,0 +1,27 @@
"""Lokaler macOS-Alarm (Systembenachrichtigung + Ton) für pipe.monitor.
Wirft nie - eine fehlgeschlagene Benachrichtigung darf die Wache nicht zum
Absturz bringen. Nutzt osascript (stdlib-Unterprozess, kein Framework, keine
Abhängigkeit von einem laufenden Nextcloud/Cloud-Dienst).
"""
from __future__ import annotations
import subprocess
from . import config
def _quote(text: str) -> str:
"""AppleScript-String-Literal - Anführungszeichen/Backslashes escapen."""
return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
def sende(titel: str, text: str) -> None:
skript = (
f"display notification {_quote(text)} with title {_quote(titel)} "
f"sound name {_quote(config.MONITOR_TON)}"
)
try:
subprocess.run(["osascript", "-e", skript], capture_output=True, timeout=10)
except Exception as fehler:
print(f" Alarm konnte nicht zugestellt werden: {fehler}")

View file

@ -168,6 +168,19 @@ def nextcloud_aktiv() -> bool:
return bool(NEXTCLOUD_URL and NEXTCLOUD_USER and NEXTCLOUD_PASS)
# --- Störungswache (pipe.monitor) --------------------------------------------
# Prueft die Dienste-Ampel (pipe.dienste: Telefon, Verarbeitung, Spracherkennung,
# Nextcloud) periodisch und alarmiert lokal per macOS-Benachrichtigung + Ton,
# wenn niemand auf den Leitstand schaut. Eigener Prozess (siehe
# telefon/starten.sh) - faellt pipe.watch oder pipe.server aus, meldet die
# Wache das trotzdem noch.
MONITOR_TAKT_S = int(os.environ.get("MONITOR_TAKT_S", "60") or "60")
# Erinnerungsabstand, waehrend eine Stoerung anhaelt - sonst nur ein einziger
# Alarm beim Ausfall, der leicht untergeht.
MONITOR_WIEDERHOLUNG_MIN = int(os.environ.get("MONITOR_WIEDERHOLUNG_MIN", "15") or "15")
# macOS-Systemton (siehe /System/Library/Sounds), der bei jedem Alarm abgespielt wird.
MONITOR_TON = os.environ.get("MONITOR_TON", "Sosumi").strip() or "Sosumi"
# --- Aufbewahrungsfrist (pipe.loeschen) --------------------------------------
# Nach so vielen Tagen wird ein Anruf-Ordner (Aufnahme + Transkript + Meta)
# geloescht - lokal und, falls hochgeladen, auch bei Nextcloud. 0 = aus (nichts

96
pipe/dienste.py Normal file
View file

@ -0,0 +1,96 @@
"""Zustand der beteiligten Dienste (Telefon, Verarbeitung, Spracherkennung,
Nextcloud) geteilt zwischen Leitstand (pipe.server) und Störungswache
(pipe.monitor), damit beide dieselbe Sicht auf "läuft/steht" haben.
"""
from __future__ import annotations
import subprocess
import time
import urllib.error
import urllib.request
from datetime import datetime
from . import config, store
# Statusabfragen sind teuer (Netz, Unterprozesse) - kurz zwischenspeichern,
# damit haeufiges Nachfragen den Rechner nicht belastet.
_CACHE: dict[str, tuple[float, dict]] = {}
CACHE_S = 4.0
def _laeuft(muster: str) -> bool:
try:
return subprocess.run(["pgrep", "-f", muster], capture_output=True).returncode == 0
except Exception:
return False
def _http_ok(url: str, timeout: float = 5.0) -> bool:
"""Erreichbarkeitstest. Mit eigenem User-Agent - ein WAF vor Nextcloud
blockt den Standardnamen von urllib und meldete fälschlich 'nicht
erreichbar'."""
req = urllib.request.Request(url, headers={"User-Agent": "praxis-telefon-agent/1.0"})
try:
with urllib.request.urlopen(req, timeout=timeout, context=store._ssl_kontext()):
return True
except urllib.error.HTTPError:
return True # antwortet - reicht als Lebenszeichen
except Exception:
return False
def _trunk() -> tuple[str, str]:
"""(zustand, text) des SIP-Trunks."""
if not _laeuft("[f]reeswitch"):
return "aus", "FreeSWITCH läuft nicht"
try:
roh = subprocess.run(
["fs_cli", "-P", config.FS_PORT, "-x", "sofia status gateway plusnet"],
capture_output=True, text=True, timeout=8).stdout
for zeile in roh.splitlines():
if zeile.startswith("Status"):
wert = zeile.split()[-1]
return ("gut", "registriert") if wert == "UP" else ("schlecht", f"Trunk {wert}")
return "unklar", "Gateway unbekannt"
except Exception:
return "unklar", "fs_cli nicht erreichbar"
def status() -> dict:
jetzt = time.time()
gepuffert = _CACHE.get("status")
if gepuffert and jetzt - gepuffert[0] < CACHE_S:
return gepuffert[1]
trunk_zustand, trunk_text = _trunk()
watcher = _laeuft("[p]ipe.watch")
ollama = _http_ok(f"{config.OLLAMA_URL}/api/version")
nextcloud = (_http_ok(f"{config.NEXTCLOUD_URL}/status.php")
if config.nextcloud_aktiv() else None)
offen = 0
if config.TELEFON_EINGANG.is_dir():
offen = sum(1 for d in config.TELEFON_EINGANG.iterdir()
if d.is_file() and not d.name.startswith("."))
fehler = 0
if config.TELEFON_FEHLER.is_dir():
fehler = sum(1 for d in config.TELEFON_FEHLER.iterdir() if d.is_file())
daten = {
"dienste": [
{"name": "Telefon", "zustand": trunk_zustand, "text": trunk_text},
{"name": "Verarbeitung", "zustand": "gut" if watcher else "aus",
"text": "beobachtet Eingang" if watcher else "Watcher läuft nicht"},
{"name": "Spracherkennung", "zustand": "gut" if ollama else "schlecht",
"text": "Ollama bereit" if ollama else "Ollama nicht erreichbar"},
{"name": "Nextcloud", "zustand": "unklar" if nextcloud is None
else ("gut" if nextcloud else "schlecht"),
"text": "nicht konfiguriert" if nextcloud is None
else ("erreichbar" if nextcloud else "nicht erreichbar")},
],
"eingang_offen": offen,
"fehler": fehler,
"stand": datetime.now().strftime("%H:%M:%S"),
}
_CACHE["status"] = (jetzt, daten)
return daten

70
pipe/monitor.py Normal file
View file

@ -0,0 +1,70 @@
"""Störungswache: prüft die Dienste-Ampel (pipe.dienste) periodisch und
alarmiert lokal (macOS-Benachrichtigung + Ton), wenn ein Dienst ausfällt -
auch wenn gerade niemand auf den Leitstand schaut.
python3 -m pipe.monitor [--einmal]
Läuft als eigener Prozess (siehe telefon/starten.sh), unabhängig von
pipe.watch und pipe.server - fällt einer der beiden aus, kann die Wache das
trotzdem noch melden. Alarmiert nur bei echten Störungen ("schlecht"/"aus"),
nicht bei "unklar" (z. B. Nextcloud nicht konfiguriert - das ist gewollt,
kein Fehler).
"""
from __future__ import annotations
import argparse
import time
from . import alarm, config, dienste
GESTOERT = {"schlecht", "aus"}
def pruefe(letzte_alarme: dict[str, float]) -> None:
"""Ein Durchlauf: Ampel abfragen, bei Bedarf alarmieren.
`letzte_alarme` hält je Dienstname den Zeitpunkt (time.monotonic) des
letzten Alarms - fehlt ein Name, gilt der Dienst aktuell als nicht
gestört. Der Aufrufer reicht dasselbe dict über alle Durchläufe weiter.
"""
jetzt = time.monotonic()
for dienst in dienste.status()["dienste"]:
name, zustand, text = dienst["name"], dienst["zustand"], dienst["text"]
letzter_alarm = letzte_alarme.get(name)
if zustand in GESTOERT:
if letzter_alarm is None:
alarm.sende(f"{name} gestört", text)
letzte_alarme[name] = jetzt
elif jetzt - letzter_alarm >= config.MONITOR_WIEDERHOLUNG_MIN * 60:
alarm.sende(f"{name} weiterhin gestört", text)
letzte_alarme[name] = jetzt
elif zustand == "gut" and letzter_alarm is not None:
alarm.sende(f"{name} wieder ok", text)
del letzte_alarme[name]
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(
description="Dienste-Ampel beobachten und bei Störung lokal alarmieren.")
p.add_argument("--einmal", action="store_true",
help="nur einmal prüfen statt dauerhaft zu beobachten")
args = p.parse_args(argv)
letzte_alarme: dict[str, float] = {}
if args.einmal:
pruefe(letzte_alarme)
return 0
print(f"Störungswache aktiv - Takt {config.MONITOR_TAKT_S}s, "
f"Erinnerung alle {config.MONITOR_WIEDERHOLUNG_MIN} min - Abbruch mit Strg-C")
try:
while True:
pruefe(letzte_alarme)
time.sleep(config.MONITOR_TAKT_S)
except KeyboardInterrupt:
print("\nBeobachtung beendet.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -13,101 +13,15 @@ import base64
import hmac
import json
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
from . import bewertung, config, dashboard, export, modellwahl, protokoll, stapel, store, testzugang
# Statusabfragen sind teuer (Netz, Unterprozesse) - kurz zwischenspeichern,
# damit haeufiges Nachfragen den Rechner nicht belastet.
_CACHE: dict[str, tuple[float, dict]] = {}
CACHE_S = 4.0
def _laeuft(muster: str) -> bool:
try:
return subprocess.run(["pgrep", "-f", muster], capture_output=True).returncode == 0
except Exception:
return False
def _http_ok(url: str, timeout: float = 5.0) -> bool:
"""Erreichbarkeitstest. Mit eigenem User-Agent - ein WAF vor Nextcloud
blockt den Standardnamen von urllib und meldete fälschlich 'nicht
erreichbar'."""
req = urllib.request.Request(url, headers={"User-Agent": "praxis-telefon-agent/1.0"})
try:
with urllib.request.urlopen(req, timeout=timeout, context=store._ssl_kontext()):
return True
except urllib.error.HTTPError:
return True # antwortet - reicht als Lebenszeichen
except Exception:
return False
def _trunk() -> tuple[str, str]:
"""(zustand, text) des SIP-Trunks."""
if not _laeuft("[f]reeswitch"):
return "aus", "FreeSWITCH läuft nicht"
try:
roh = subprocess.run(
["fs_cli", "-P", config.FS_PORT, "-x", "sofia status gateway plusnet"],
capture_output=True, text=True, timeout=8).stdout
for zeile in roh.splitlines():
if zeile.startswith("Status"):
wert = zeile.split()[-1]
return ("gut", "registriert") if wert == "UP" else ("schlecht", f"Trunk {wert}")
return "unklar", "Gateway unbekannt"
except Exception:
return "unklar", "fs_cli nicht erreichbar"
def status() -> dict:
jetzt = time.time()
gepuffert = _CACHE.get("status")
if gepuffert and jetzt - gepuffert[0] < CACHE_S:
return gepuffert[1]
trunk_zustand, trunk_text = _trunk()
watcher = _laeuft("[p]ipe.watch")
ollama = _http_ok(f"{config.OLLAMA_URL}/api/version")
nextcloud = (_http_ok(f"{config.NEXTCLOUD_URL}/status.php")
if config.nextcloud_aktiv() else None)
offen = 0
if config.TELEFON_EINGANG.is_dir():
offen = sum(1 for d in config.TELEFON_EINGANG.iterdir()
if d.is_file() and not d.name.startswith("."))
fehler = 0
if config.TELEFON_FEHLER.is_dir():
fehler = sum(1 for d in config.TELEFON_FEHLER.iterdir() if d.is_file())
daten = {
"dienste": [
{"name": "Telefon", "zustand": trunk_zustand, "text": trunk_text},
{"name": "Verarbeitung", "zustand": "gut" if watcher else "aus",
"text": "beobachtet Eingang" if watcher else "Watcher läuft nicht"},
{"name": "Spracherkennung", "zustand": "gut" if ollama else "schlecht",
"text": "Ollama bereit" if ollama else "Ollama nicht erreichbar"},
{"name": "Nextcloud", "zustand": "unklar" if nextcloud is None
else ("gut" if nextcloud else "schlecht"),
"text": "nicht konfiguriert" if nextcloud is None
else ("erreichbar" if nextcloud else "nicht erreichbar")},
],
"eingang_offen": offen,
"fehler": fehler,
"stand": datetime.now().strftime("%H:%M:%S"),
}
_CACHE["status"] = (jetzt, daten)
return daten
from . import bewertung, config, dashboard, dienste, export, modellwahl, protokoll, stapel, testzugang
def anrufe() -> list[dict]:
@ -325,7 +239,7 @@ class Handler(BaseHTTPRequestHandler):
if pfad == "/":
self._sende(SEITE.encode("utf-8"), "text/html; charset=utf-8")
elif pfad == "/api/status":
daten = status()
daten = dienste.status()
# Testzugang (per "agent-one test"-Link) -> Sicher-Modus im
# Frontend erzwingen, siehe sicherSetzen() im JS.
daten["testzugang"] = getattr(self, "_via_token", False)