diff --git a/.env.example b/.env.example index c2d13dd..cf413a0 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,14 @@ NEXTCLOUD_ADRESSBUCH=contacts # Anrufe bleiben unangetastet, egal wie alt. LOESCHFRIST_TAGE=0 +# --- Störungswache (pipe.monitor, eigener Prozess, siehe telefon/starten.sh) --- +# Prüft die Dienste-Ampel (Telefon, Verarbeitung, Spracherkennung, Nextcloud) +# und alarmiert lokal per macOS-Benachrichtigung + Ton, wenn niemand auf den +# Leitstand schaut. +MONITOR_TAKT_S=60 +MONITOR_WIEDERHOLUNG_MIN=15 +MONITOR_TON=Sosumi + # --- Telefonie (SIP-Zugang der Praxis, leer lassen = keine Telefonannahme) --- SIP_USER= SIP_DOMAIN=sip.plusnet.de diff --git a/README.md b/README.md index 32aa600..7e33acc 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,10 @@ sammelt die Erfahrungswerte, die in dieser Anleitung nicht stehen. 1. **Die Pipe** (fertig): Audio → Transkript → Kategorie → Ablage. 2. **Telefonie** (steht): FreeSWITCH nimmt über den Plusnet-Trunk an, spielt die Ansage, nimmt auf und legt die Datei im Eingang der Pipe ab. -3. **Feinschliff** (teilweise): Löschfristen ✅, Notfall-Alarm ✅ — offen: - Monitoring/Alerting bei Diensteausfall, Verschlüsselung at rest. +3. **Feinschliff** (teilweise): Löschfristen ✅, Notfall-Alarm ✅, + Verschlüsselung at rest ✅ (Betreiber-Pflicht: FileVault, siehe + [Datenschutz](#datenschutz-dsgvo)), Monitoring/Alerting bei Diensteausfall ✅ + (`pipe/monitor.py`, siehe unten). ## Bausteine @@ -37,6 +39,9 @@ sammelt die Erfahrungswerte, die in dieser Anleitung nicht stehen. | `pipe/config.py` | Konfiguration aus `.env` | | `pipe/watch.py` | Beobachtet den Eingang und schiebt Aufnahmen durch die Pipe | | `pipe/server.py` | Leitstand: Live-Ansicht mit Status, Anrufen und Verlauf | +| `pipe/dienste.py` | Zustand der beteiligten Dienste (Telefon, Verarbeitung, Spracherkennung, Nextcloud) — geteilt von Leitstand und Störungswache | +| `pipe/monitor.py` | Störungswache: alarmiert lokal (macOS-Benachrichtigung + Ton), wenn ein Dienst ausfällt | +| `pipe/alarm.py` | Verschickt den lokalen macOS-Alarm (osascript) | | `pipe/protokoll.py` | Ereignisprotokoll (JSONL) für den Leitstand | | `pipe/audit.py` | Unveränderliches Audit-Log (append-only, siehe unten) | | `pipe/loeschen.py` | Löscht erledigte Anrufe nach Ablauf der Aufbewahrungsfrist | @@ -247,6 +252,29 @@ in `telefon/audit.log` — anders als `verlauf.log` (fürs Leitstand-UI, wird au zusätzlich per macOS `chflags uappnd` gegen nachträgliches Ändern/Kürzen abgesichert (auch der eigene Prozess kommt danach nur noch per Anhängen rein). +## Störungswache (Monitoring/Alerting) + +Die Dienste-Ampel im Leitstand (Telefon, Verarbeitung, Spracherkennung, +Nextcloud) zeigt den Zustand nur an, wenn gerade jemand die Seite offen hat. +`pipe/monitor.py` läuft deshalb als **eigener Prozess** (von +`telefon/starten.sh` mitgestartet, Log `telefon/monitor.log`), fragt dieselbe +Ampel (`pipe/dienste.py`) alle `MONITOR_TAKT_S` Sekunden (Default 60) ab und +alarmiert bei einer echten Störung (`schlecht`/`aus` — nicht bei `unklar`, +z. B. Nextcloud unkonfiguriert) lokal per macOS-Systembenachrichtigung + Ton +(`pipe/alarm.py`, `osascript`). Solange die Störung anhält, wird alle +`MONITOR_WIEDERHOLUNG_MIN` Minuten (Default 15) erneut erinnert; bei +Wiederherstellung einmalig „wieder ok". + +Bewusst ein eigener, dritter Prozess statt in `pipe.watch` oder `pipe.server` +eingebaut: fällt einer der beiden aus, kann die Wache das trotzdem noch +melden. Wirkt nur lokal auf diesem Mac — für Alarme aufs Handy (z. B. per +Push/E-Mail) `pipe/alarm.py` erweitern. + +```bash +python3 -m pipe.monitor # dauerhaft beobachten (normalerweise per starten.sh) +python3 -m pipe.monitor --einmal # einmal prüfen, für Tests +``` + ## Bewertung & Trainingsdaten-Export Jede Karte im Leitstand hat 👍/👎 — war die Kategorisierung richtig? Bei 👎 @@ -275,3 +303,17 @@ Gesundheitsdaten — Pflichten für den Produktivbetrieb (Stufe 2): > beiden Punkte noch nicht (kein Notfall-Hinweis, keine Aufnahme-Einwilligung) > — Text anpassen und `ansage_bauen.sh` neu laufen lassen, bevor die Anlage im > echten Praxisbetrieb ans Netz geht. + +> **Verschlüsselung at rest — Pflicht des Betreibers:** Die Pipe legt +> Aufnahmen, Transkripte und Metadaten (`ablage/`, `telefon/`, `training/`) +> unverschlüsselt auf der Platte ab und verlässt sich auf +> Festplattenverschlüsselung durch das Betriebssystem. **FileVault muss auf +> jedem Rechner aktiviert sein, der diese Pipe betreibt** +> (Systemeinstellungen → Datenschutz & Sicherheit → FileVault, oder +> `sudo fdesetup enable`) — den Recovery-Key sicher verwahren (Passwort- +> Tresor), da er das einzige Mittel bei vergessenem Login-Passwort ist. +> Ist FileVault auf einem Zielsystem nicht aktivierbar oder nicht zulässig +> (z. B. Mehrbenutzer-Rechner, andere Auflagen), reicht das nicht aus — dann +> braucht die App eine eigene Verschlüsselung der Ablage, die es aktuell +> **nicht** gibt. Das Aktivieren/Prüfen von FileVault ist Aufgabe des +> Betreibers vor Ort, nicht Teil dieses Repos. diff --git a/pipe/alarm.py b/pipe/alarm.py new file mode 100644 index 0000000..a19ac2f --- /dev/null +++ b/pipe/alarm.py @@ -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}") diff --git a/pipe/config.py b/pipe/config.py index c62176b..fb46e4b 100644 --- a/pipe/config.py +++ b/pipe/config.py @@ -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 diff --git a/pipe/dienste.py b/pipe/dienste.py new file mode 100644 index 0000000..c17342f --- /dev/null +++ b/pipe/dienste.py @@ -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 diff --git a/pipe/monitor.py b/pipe/monitor.py new file mode 100644 index 0000000..b60bcf4 --- /dev/null +++ b/pipe/monitor.py @@ -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()) diff --git a/pipe/server.py b/pipe/server.py index a7b092e..eb2f3fb 100644 --- a/pipe/server.py +++ b/pipe/server.py @@ -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) diff --git a/telefon/starten.sh b/telefon/starten.sh index 5bc2b82..17e3141 100755 --- a/telefon/starten.sh +++ b/telefon/starten.sh @@ -109,6 +109,12 @@ if [ "${status:-}" = "UP" ]; then sleep 2 head -1 telefon/leitstand.log fi + if pgrep -f "[p]ipe.monitor" >/dev/null; then + echo "Stoerungswache laeuft bereits." + else + nohup python3 -u -m pipe.monitor > telefon/monitor.log 2>&1 & + echo "Stoerungswache gestartet (Log: telefon/monitor.log)" + fi sleep 2 echo ip=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null)