Add HTTPS management console and response planning
This commit is contained in:
parent
a56d72edd6
commit
a7129e5666
16 changed files with 927 additions and 160 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue