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>
This commit is contained in:
Luna 2026-05-31 16:18:00 -07:00
parent 0eb5077551
commit c00fff224c
17 changed files with 960 additions and 5 deletions

244
enodia_sentinel/web.py Normal file
View file

@ -0,0 +1,244 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Read-only web dashboard, served by the stdlib http.server (zero deps).
Exposes a small JSON API over the log directory plus a single self-contained
page. It is deliberately read-only no actions, no writes because it serves
sensitive forensic data. By default it binds the host's Tailscale address and
requires a bearer token; on loopback the token is optional.
"""
from __future__ import annotations
import hmac
import json
import os
import re
import secrets
import subprocess
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from . import __version__
from .config import Config
_STATIC = Path(__file__).parent / "static"
_ALERT_NAME = re.compile(r"^alert-\d{8}-\d{6}\.(json|log)$")
# --- network helpers -------------------------------------------------------
def detect_tailscale_ip() -> str | None:
"""Best-effort Tailscale IPv4 of this host."""
try:
out = subprocess.run(["tailscale", "ip", "-4"], capture_output=True,
text=True, timeout=4).stdout.strip().splitlines()
if out and out[0]:
return out[0].strip()
except (OSError, subprocess.SubprocessError):
pass
try:
out = subprocess.run(["ip", "-o", "-4", "addr", "show", "tailscale0"],
capture_output=True, text=True, timeout=4).stdout
m = re.search(r"inet (\d+\.\d+\.\d+\.\d+)", out)
if m:
return m.group(1)
except (OSError, subprocess.SubprocessError):
pass
return None
def resolve_bind(cfg: Config) -> str:
if cfg.web_bind:
return cfg.web_bind
return detect_tailscale_ip() or "127.0.0.1"
def is_loopback(addr: str) -> bool:
return addr.startswith("127.") or addr in ("::1", "localhost")
def ensure_token(cfg: Config) -> str:
if cfg.web_token:
return cfg.web_token
path = cfg.log_dir / ".web_token"
try:
if path.is_file():
tok = path.read_text().strip()
if tok:
return tok
except OSError:
pass
tok = secrets.token_urlsafe(24)
try:
cfg.log_dir.mkdir(parents=True, exist_ok=True)
path.write_text(tok)
path.chmod(0o600)
except OSError:
pass
return tok
# --- data layer (pure, testable) ------------------------------------------
def list_alerts(cfg: Config, limit: int = 200) -> list[dict]:
out = []
for p in sorted(cfg.log_dir.glob("alert-*.json"), reverse=True)[:limit]:
try:
d = json.loads(p.read_text())
except (OSError, ValueError):
continue
out.append({
"name": p.with_suffix(".log").name,
"time": d.get("time", ""),
"host": d.get("host", ""),
"severity": d.get("severity", ""),
"signatures": sorted({a.get("signature", "") for a in d.get("alerts", [])}),
"sids": sorted({a.get("sid", 0) for a in d.get("alerts", []) if a.get("sid")}),
"count": len(d.get("alerts", [])),
})
return out
def get_alert(cfg: Config, name: str) -> dict | None:
if not _ALERT_NAME.match(name):
return None
base = cfg.log_dir / name
jpath = base.with_suffix(".json")
try:
data = {"json": json.loads(jpath.read_text())} if jpath.is_file() else {}
lpath = base.with_suffix(".log")
if lpath.is_file():
data["text"] = lpath.read_text()
return data or None
except (OSError, ValueError):
return None
def tail_events(cfg: Config, limit: int = 100) -> list[str]:
try:
lines = cfg.events_log.read_text().splitlines()
except OSError:
return []
return lines[-limit:]
def daemon_status(cfg: Config) -> dict:
pid_alive = False
pidfile = cfg.log_dir / "sentinel.pid"
try:
pid = int(pidfile.read_text().strip())
os.kill(pid, 0)
pid_alive = True
except (OSError, ValueError):
pid_alive = False
alerts = list_alerts(cfg, limit=10000)
counts: dict[str, int] = {}
for a in alerts:
counts[a["severity"]] = counts.get(a["severity"], 0) + 1
ebpf = "unknown"
for line in reversed(tail_events(cfg, 200)):
if "eBPF exec monitor:" in line:
ebpf = line.split("eBPF exec monitor:", 1)[1].strip()
break
return {
"version": __version__,
"running": pid_alive,
"total_alerts": len(alerts),
"counts": counts,
"last_alert": alerts[0]["time"] if alerts else None,
"ebpf": ebpf,
"host": os.uname().nodename,
}
# --- HTTP layer ------------------------------------------------------------
class _Handler(BaseHTTPRequestHandler):
server_version = "EnodiaSentinel"
def log_message(self, *a): # quiet by default
pass
def _authorized(self) -> bool:
token = self.server.token # type: ignore[attr-defined]
if not token:
return True
supplied = ""
auth = self.headers.get("Authorization", "")
if auth.startswith("Bearer "):
supplied = auth[7:]
if not supplied:
supplied = parse_qs(urlparse(self.path).query).get("token", [""])[0]
return hmac.compare_digest(supplied, token)
def _json(self, obj, status=200):
body = json.dumps(obj).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
path = urlparse(self.path).path
if not self._authorized():
self._json({"error": "unauthorized"}, 401)
return
cfg: Config = self.server.cfg # type: ignore[attr-defined]
if path in ("/", "/index.html", "/dashboard.html"):
self._serve_static("dashboard.html", "text/html; charset=utf-8")
elif path == "/api/status":
self._json(daemon_status(cfg))
elif path == "/api/alerts":
self._json(list_alerts(cfg))
elif path.startswith("/api/alerts/"):
alert = get_alert(cfg, path.rsplit("/", 1)[-1])
self._json(alert if alert else {"error": "not found"},
200 if alert else 404)
elif path == "/api/events":
self._json({"events": tail_events(cfg)})
else:
self._json({"error": "not found"}, 404)
def _serve_static(self, name, ctype):
try:
body = (_STATIC / name).read_bytes()
except OSError:
self._json({"error": "missing asset"}, 500)
return
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def build_server(cfg: Config) -> tuple[ThreadingHTTPServer, str, str]:
"""Construct the server (without serving). Returns (httpd, bind, token)."""
bind = resolve_bind(cfg)
token = cfg.web_token
if not is_loopback(bind) and not token:
token = ensure_token(cfg)
httpd = ThreadingHTTPServer((bind, cfg.web_port), _Handler)
httpd.cfg = cfg # type: ignore[attr-defined]
httpd.token = token # type: ignore[attr-defined]
return httpd, bind, token
def serve(cfg: Config) -> None:
httpd, bind, token = build_server(cfg)
suffix = f"/?token={token}" if token else "/"
url = f"http://{bind}:{cfg.web_port}{suffix}"
msg = f"{time.strftime('%FT%T%z')} dashboard serving at {url}"
print(msg, flush=True)
try:
with open(cfg.events_log, "a") as fh:
fh.write(msg + "\n")
except OSError:
pass
try:
httpd.serve_forever()
except KeyboardInterrupt:
httpd.shutdown()