enodia-sentinal/enodia_sentinel/notify/__init__.py
Luna c00fff224c Add read-only web dashboard and phone push notifications
Both zero-dependency (stdlib http.server + urllib), consistent with the
project's no-dependency-tree stance for a security daemon.

Web dashboard (enodia_sentinel/web.py + static/dashboard.html):
- read-only JSON API over the log dir: /api/status, /api/alerts,
  /api/alerts/<id>, /api/events
- bearer-token auth (constant-time; header or ?token=), required on non-
  loopback binds, auto-generated + persisted (0600) when unset
- binds the host's Tailscale IP by default (auto-detected), reachable from the
  tailnet but not the LAN/internet
- self-contained dark SPA: severity cards, live alert list, full snapshot
  viewer; 10s auto-refresh
- path-traversal-safe alert lookup; `enodia-sentinel web` subcommand; daemon
  now writes a pidfile so the dashboard can show live status
- hardened enodia-sentinel-web.service (read-only, no caps)

Phone push (enodia_sentinel/notify/):
- pluggable backends — ntfy, Pushover, generic webhook — each separating a pure
  build() (unit-tested, no network) from send()
- a backend turns on when its config keys are set; pushes gated by
  notify_min_severity; severity → per-service priority/tags
- fired from snapshot.capture on worker threads, errors swallowed
- desktop notify-send retained

Tests: +16 (9 web incl. a real-server 401/200 auth test, 7 notify request-build
cases). 55/55 pass. Live end-to-end verified: daemon → alert → API → page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:18:00 -07:00

88 lines
3.1 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
"""Outbound notifications: desktop + phone push (ntfy / Pushover / webhook).
A backend is *enabled* when its required config keys are present, so turning one
on is purely a matter of configuration. Phone pushes are gated by
``notify_min_severity``. Everything sends on a worker thread and swallows its
own errors — a flaky notifier must never stall or crash detection.
"""
from __future__ import annotations
import threading
from dataclasses import dataclass, field
from datetime import datetime
from ..alert import Alert, Severity
from ..config import Config
from . import backends
_SEV = {"MEDIUM": Severity.MEDIUM, "HIGH": Severity.HIGH,
"CRITICAL": Severity.CRITICAL}
_PUSH_BACKENDS = (backends.Ntfy, backends.Pushover, backends.Webhook)
@dataclass(frozen=True)
class Notification:
severity: Severity
host: str
signatures: tuple[str, ...]
sids: tuple[int, ...]
snapshot_name: str
count: int
time: str = field(default_factory=lambda: datetime.now().astimezone().isoformat())
dashboard_url: str = ""
@classmethod
def from_alerts(cls, alerts: list[Alert], host: str, snapshot_name: str,
dashboard_url: str = "") -> "Notification":
sev = max((a.severity for a in alerts), default=Severity.HIGH)
sigs = tuple(dict.fromkeys(a.signature for a in alerts))
sids = tuple(dict.fromkeys(a.sid for a in alerts if a.sid))
return cls(severity=sev, host=host, signatures=sigs, sids=sids,
snapshot_name=snapshot_name, count=len(alerts),
dashboard_url=dashboard_url)
def title(self) -> str:
return f"Enodia: {self.severity} on {self.host}"
def message(self) -> str:
lines = [f"{self.count} detection(s): {', '.join(self.signatures)}"]
if self.sids:
lines.append("sids: " + ", ".join(str(s) for s in self.sids))
lines.append(f"snapshot: {self.snapshot_name}")
if self.dashboard_url:
lines.append(self.dashboard_url.rstrip("/") + "/")
return "\n".join(lines)
def to_dict(self) -> dict:
return {
"time": self.time, "host": self.host,
"severity": str(self.severity), "signatures": list(self.signatures),
"sids": list(self.sids), "snapshot": self.snapshot_name,
"count": self.count,
}
def min_severity(cfg: Config) -> Severity:
return _SEV.get(cfg.notify_min_severity.upper(), Severity.HIGH)
def enabled_backends(cfg: Config) -> list:
return [b for b in _PUSH_BACKENDS if b.enabled(cfg)]
def dispatch(cfg: Config, notif: Notification) -> None:
"""Fire all enabled phone-push backends (on worker threads, best-effort)."""
if notif.severity < min_severity(cfg):
return
for backend in enabled_backends(cfg):
threading.Thread(target=_safe_send, args=(backend, cfg, notif),
daemon=True).start()
def _safe_send(backend, cfg: Config, notif: Notification) -> None:
try:
backend.send(cfg, notif)
except Exception:
pass