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>
244 lines
7.8 KiB
Python
244 lines
7.8 KiB
Python
# 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()
|