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:
parent
0eb5077551
commit
c00fff224c
17 changed files with 960 additions and 5 deletions
244
enodia_sentinel/web.py
Normal file
244
enodia_sentinel/web.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue