Add HTTPS management console and response planning

This commit is contained in:
Luna 2026-06-12 02:39:53 -07:00
parent a56d72edd6
commit a7129e5666
16 changed files with 927 additions and 160 deletions

View file

@ -1,23 +1,27 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Read-only web dashboard, served by the stdlib http.server (zero deps).
"""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. By default it binds the host's Tailscale address and
requires a bearer token; on loopback the token is optional.
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, urlparse
from urllib.parse import parse_qs, unquote, urlparse
from . import __version__
from .config import Config
@ -79,6 +83,53 @@ def ensure_token(cfg: Config) -> str:
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]:
@ -156,6 +207,51 @@ def daemon_status(cfg: Config) -> dict:
}
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)
# --- HTTP layer ------------------------------------------------------------
class _Handler(BaseHTTPRequestHandler):
@ -203,6 +299,18 @@ class _Handler(BaseHTTPRequestHandler):
200 if alert else 404)
elif path == "/api/events":
self._json({"events": tail_events(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)
@ -220,22 +328,29 @@ class _Handler(BaseHTTPRequestHandler):
def build_server(cfg: Config) -> tuple[ThreadingHTTPServer, str, str]:
"""Construct the server (without serving). Returns (httpd, bind, token)."""
"""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"http://{bind}:{cfg.web_port}{suffix}"
msg = f"{time.strftime('%FT%T%z')} dashboard serving at {url}"
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: