enodia-sentinal/enodia_sentinel/web.py
2026-06-15 16:37:51 -07:00

411 lines
14 KiB
Python

# 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 .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 {
"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 {"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,
}
# --- 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)})
elif path == "/api/posture":
self._json(posture_report(cfg))
elif path == "/api/rules":
self._json(rules_catalog(cfg))
elif path == "/api/incidents":
self._json(list_incidents(cfg))
elif path.startswith("/api/incidents/"):
iid = unquote(path.rsplit("/", 1)[-1])
inc = get_incident(cfg, iid)
self._json(inc if inc else {"error": "not found"},
200 if inc else 404)
elif path.startswith("/api/respond/plan/"):
iid = unquote(path.rsplit("/", 1)[-1])
plan = response_plan(cfg, iid)
self._json(plan if plan else {"error": "not found"},
200 if plan else 404)
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 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()