mirror of
https://github.com/Jeuners/Logpy-AgentOne.git
synced 2026-09-13 00:42:33 +02:00
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:
parent
be0252c907
commit
6e5fe59c53
8 changed files with 266 additions and 90 deletions
27
pipe/alarm.py
Normal file
27
pipe/alarm.py
Normal 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}")
|
||||
|
|
@ -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
96
pipe/dienste.py
Normal 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
70
pipe/monitor.py
Normal 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())
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue