# 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()