# SPDX-License-Identifier: GPL-3.0-or-later """Read-only HTTPS 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. TLS is mandatory, using configured certificates or an auto-generated self-signed certificate under ``log_dir``. 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 ipaddress import json import os import re import secrets import ssl import subprocess import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, unquote, urlparse from . import __version__ from . import schemas 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 def tls_paths(cfg: Config) -> tuple[Path, Path]: cert = Path(cfg.web_tls_cert) if cfg.web_tls_cert else cfg.log_dir / "web-selfsigned.crt" key = Path(cfg.web_tls_key) if cfg.web_tls_key else cfg.log_dir / "web-selfsigned.key" return cert, key def _san_for(bind: str) -> str: names = ["DNS:localhost", "IP:127.0.0.1"] host = os.uname().nodename if host: names.append(f"DNS:{host}") try: ipaddress.ip_address(bind) names.append(f"IP:{bind}") except ValueError: if bind and bind != "localhost": names.append(f"DNS:{bind}") return ",".join(dict.fromkeys(names)) def ensure_tls_cert(cfg: Config, bind: str) -> tuple[Path, Path]: """Return usable cert/key paths, generating a self-signed pair if absent.""" cert, key = tls_paths(cfg) if cert.is_file() and key.is_file(): return cert, key cert.parent.mkdir(parents=True, exist_ok=True) key.parent.mkdir(parents=True, exist_ok=True) subj = f"/CN={bind if bind else 'localhost'}" cmd = [ "openssl", "req", "-x509", "-newkey", "rsa:3072", "-nodes", "-sha256", "-days", "365", "-keyout", str(key), "-out", str(cert), "-subj", subj, "-addext", f"subjectAltName={_san_for(bind)}", ] try: subprocess.run(cmd, capture_output=True, text=True, timeout=20, check=True) key.chmod(0o600) cert.chmod(0o644) except (OSError, subprocess.SubprocessError) as exc: raise RuntimeError( "TLS is mandatory for the web dashboard, but no certificate/key " "were available and self-signed generation with openssl failed" ) from exc return cert, key # --- 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_exec = "unknown" ebpf_syscall = "unknown" for line in reversed(tail_events(cfg, 200)): if ebpf_exec == "unknown" and "eBPF exec monitor:" in line: ebpf_exec = line.split("eBPF exec monitor:", 1)[1].strip() if ebpf_syscall == "unknown" and "eBPF syscall monitor:" in line: ebpf_syscall = line.split("eBPF syscall monitor:", 1)[1].strip() if ebpf_exec != "unknown" and ebpf_syscall != "unknown": break from .selfprotect import heartbeat_age age = heartbeat_age(cfg) return { "schema": schemas.STATUS_V1, "version": __version__, "running": pid_alive, "total_alerts": len(alerts), "counts": counts, "last_alert": alerts[0]["time"] if alerts else None, "ebpf": ebpf_exec, "ebpf_exec": ebpf_exec, "ebpf_syscall": ebpf_syscall, "host": os.uname().nodename, "heartbeat_age": age, "heartbeat_stale": (age is not None and age > cfg.heartbeat_max_age), } def list_incidents(cfg: Config) -> list[dict]: from . import incident return sorted(incident.load_index(cfg).values(), key=lambda i: i.get("last_ts", 0.0), reverse=True) def get_incident(cfg: Config, incident_id: str) -> dict | None: from . import incident inc = incident.load_index(cfg).get(incident_id) if inc is None: return None snapshots = [] timeline = [] for name in inc.get("snapshots", []): path = (cfg.log_dir / name).with_suffix(".json") try: report = json.loads(path.read_text()) except (OSError, ValueError): timeline.append({"snapshot": name, "time": "?", "missing": True}) continue snapshots.append(report) timeline.append({ "snapshot": name, "time": report.get("time", "?"), "severity": report.get("severity", "?"), "signatures": list(dict.fromkeys( a.get("signature", "") for a in report.get("alerts", []))), "pids": [p.get("pid") for p in report.get("processes", []) if isinstance(p.get("pid"), int)], }) timeline.sort(key=lambda r: r.get("time", "")) return { "schema": schemas.INCIDENT_VIEW_V1, "incident": inc, "timeline": timeline, "snapshots": snapshots, } def response_plan(cfg: Config, incident_id: str) -> dict | None: from . import respond bundle = respond.load_bundle(cfg, incident_id) if bundle is None: return None return respond.build_plan(bundle, cfg) def posture_report(cfg: Config, runner=None) -> dict: """Return advisory host posture findings for the management console.""" if runner is None: from . import posture runner = posture.run findings = sorted(runner(cfg), key=lambda a: (-a.severity, a.signature)) counts: dict[str, int] = {} for f in findings: sev = str(f.severity) counts[sev] = counts.get(sev, 0) + 1 return { "count": len(findings), "counts": counts, "findings": [f.to_dict() for f in findings], } def rules_catalog(cfg: Config) -> dict: """Return active event-rule metadata for the management console.""" from . import ruleops rules = ruleops.list_rules(cfg) by_event: dict[str, int] = {} by_severity: dict[str, int] = {} by_origin: dict[str, int] = {} for r in rules: by_event[r["event"]] = by_event.get(r["event"], 0) + 1 by_severity[r["severity"]] = by_severity.get(r["severity"], 0) + 1 by_origin[r["origin"]] = by_origin.get(r["origin"], 0) + 1 return { "count": len(rules), "by_event": by_event, "by_severity": by_severity, "by_origin": by_origin, "rules": rules, } def _json_file_state(path: Path, now: float) -> dict: try: st = path.stat() except OSError: return { "path": str(path), "exists": False, "status": "missing", "mtime": None, "age": None, "count": None, } try: data = json.loads(path.read_text()) except (OSError, ValueError): return { "path": str(path), "exists": True, "status": "unreadable", "mtime": st.st_mtime, "age": max(0.0, now - st.st_mtime), "count": None, } count = len(data) if isinstance(data, dict) else None return { "path": str(path), "exists": True, "status": "ok", "mtime": st.st_mtime, "age": max(0.0, now - st.st_mtime), "count": count, } def integrity_report(cfg: Config, status: dict | None = None, now: float | None = None) -> dict: """Return dashboard-readable integrity and watchdog state. This is intentionally a summary of existing anchors and heartbeats. It does not run live FIM or package verification work from the web request path. """ from . import pkgdb from .selfprotect import SELF_PATHS, heartbeat_path, watchdog_verdict ts = time.time() if now is None else now status = status or daemon_status(cfg) watchdog_ok, watchdog_message = watchdog_verdict(status, cfg.heartbeat_max_age) anchor = pkgdb.load_anchor(cfg) anchor_path = pkgdb._anchor_path(cfg) anchor_time = anchor.get("time") if isinstance(anchor, dict) else None sig_alert = pkgdb.siglevel_alert() watched = [] for raw in SELF_PATHS: path = Path(raw) watched.append({"path": raw, "exists": path.exists()}) present = sum(1 for item in watched if item["exists"]) fim_state = _json_file_state(cfg.fim_baseline, ts) pkg_state = { "path": str(anchor_path), "exists": bool(anchor), "status": "ok" if anchor else "missing", "time": anchor_time, "age": max(0.0, ts - anchor_time) if isinstance(anchor_time, (int, float)) else None, "fingerprint_prefix": ( str(anchor.get("fingerprint", ""))[:16] if isinstance(anchor, dict) else "" ), } keyring_present = pkgdb.keyring_present() checks = { "watchdog": "ok" if watchdog_ok else "review", "fim_baseline": fim_state["status"], "pkgdb_anchor": pkg_state["status"], "pacman_siglevel": "review" if sig_alert else "ok", "pacman_keyring": "ok" if keyring_present else "missing", } overall = "ok" if any(v in ("review", "missing", "unreadable") for v in checks.values()): overall = "review" if not watchdog_ok: overall = "critical" return { "schema": schemas.INTEGRITY_V1, "generated_at": ts, "status": overall, "checks": checks, "watchdog": { "ok": watchdog_ok, "message": watchdog_message, "heartbeat_path": str(heartbeat_path(cfg)), "heartbeat_age": status.get("heartbeat_age"), "heartbeat_stale": status.get("heartbeat_stale", False), "max_age": cfg.heartbeat_max_age, "daemon_running": status.get("running", False), }, "anchors": { "fim_baseline": fim_state, "pkgdb": pkg_state, "pacman": { "keyring_present": keyring_present, "siglevel_status": "review" if sig_alert else "ok", "siglevel_alert": sig_alert.to_dict() if sig_alert else None, }, }, "sentinel_footprint": { "configured": len(watched), "present": present, "missing": len(watched) - present, "paths": watched, }, "read_only": True, } # --- 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 _send_body(self, body: bytes, ctype: str, status=200, send_body=True): self.send_response(status) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) self.end_headers() if send_body: self.wfile.write(body) def _json(self, obj, status=200, send_body=True): body = json.dumps(obj).encode("utf-8") self._send_body(body, "application/json", status, send_body) def _json_or_not_found(self, obj, send_body=True): self._json(obj if obj else {"error": "not found"}, 200 if obj else 404, send_body=send_body) def do_GET(self): self._handle_get(send_body=True) def do_HEAD(self): self._handle_get(send_body=False) def _handle_get(self, send_body=True): path = urlparse(self.path).path if not self._authorized(): self._json({"error": "unauthorized"}, 401, send_body=send_body) 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", send_body=send_body) elif path == "/api/status": self._json(daemon_status(cfg), send_body=send_body) elif path == "/api/alerts": self._json(list_alerts(cfg), send_body=send_body) elif path.startswith("/api/alerts/"): alert = get_alert(cfg, path.rsplit("/", 1)[-1]) self._json_or_not_found(alert, send_body=send_body) elif path == "/api/events": self._json({"events": tail_events(cfg)}, send_body=send_body) elif path == "/api/posture": self._json(posture_report(cfg), send_body=send_body) elif path == "/api/rules": self._json(rules_catalog(cfg), send_body=send_body) elif path == "/api/integrity": self._json(integrity_report(cfg), send_body=send_body) elif path == "/api/incidents": self._json(list_incidents(cfg), send_body=send_body) elif path.startswith("/api/incidents/"): iid = unquote(path.rsplit("/", 1)[-1]) inc = get_incident(cfg, iid) self._json_or_not_found(inc, send_body=send_body) elif path.startswith("/api/respond/plan/"): iid = unquote(path.rsplit("/", 1)[-1]) plan = response_plan(cfg, iid) self._json_or_not_found(plan, send_body=send_body) else: self._json({"error": "not found"}, 404, send_body=send_body) def _serve_static(self, name, ctype, send_body=True): try: body = (_STATIC / name).read_bytes() except OSError: self._json({"error": "missing asset"}, 500, send_body=send_body) return self._send_body(body, ctype, send_body=send_body) def build_server(cfg: Config) -> tuple[ThreadingHTTPServer, str, str]: """Construct the HTTPS server. Returns (httpd, bind, token).""" bind = resolve_bind(cfg) token = cfg.web_token if not is_loopback(bind) and not token: token = ensure_token(cfg) cert, key = ensure_tls_cert(cfg, bind) httpd = ThreadingHTTPServer((bind, cfg.web_port), _Handler) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.minimum_version = ssl.TLSVersion.TLSv1_2 ctx.load_cert_chain(certfile=str(cert), keyfile=str(key)) httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True) httpd.cfg = cfg # type: ignore[attr-defined] httpd.token = token # type: ignore[attr-defined] httpd.tls_cert = cert # 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"https://{bind}:{cfg.web_port}{suffix}" msg = (f"{time.strftime('%FT%T%z')} dashboard serving at {url} " f"(TLS cert: {getattr(httpd, 'tls_cert', '')})") 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()