Add read-only web dashboard and phone push notifications

Both zero-dependency (stdlib http.server + urllib), consistent with the
project's no-dependency-tree stance for a security daemon.

Web dashboard (enodia_sentinel/web.py + static/dashboard.html):
- read-only JSON API over the log dir: /api/status, /api/alerts,
  /api/alerts/<id>, /api/events
- bearer-token auth (constant-time; header or ?token=), required on non-
  loopback binds, auto-generated + persisted (0600) when unset
- binds the host's Tailscale IP by default (auto-detected), reachable from the
  tailnet but not the LAN/internet
- self-contained dark SPA: severity cards, live alert list, full snapshot
  viewer; 10s auto-refresh
- path-traversal-safe alert lookup; `enodia-sentinel web` subcommand; daemon
  now writes a pidfile so the dashboard can show live status
- hardened enodia-sentinel-web.service (read-only, no caps)

Phone push (enodia_sentinel/notify/):
- pluggable backends — ntfy, Pushover, generic webhook — each separating a pure
  build() (unit-tested, no network) from send()
- a backend turns on when its config keys are set; pushes gated by
  notify_min_severity; severity → per-service priority/tags
- fired from snapshot.capture on worker threads, errors swallowed
- desktop notify-send retained

Tests: +16 (9 web incl. a real-server 401/200 auth test, 7 notify request-build
cases). 55/55 pass. Live end-to-end verified: daemon → alert → API → page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-31 16:18:00 -07:00
parent 0eb5077551
commit c00fff224c
17 changed files with 960 additions and 5 deletions

View file

@ -6,4 +6,4 @@ captures a forensic snapshot with incident-response guidance whenever a known
attack signature appears. The Python re-architecture of the bash v0 prototype.
"""
__version__ = "0.3.0"
__version__ = "0.4.0"

View file

@ -61,6 +61,7 @@ def main(argv: list[str] | None = None) -> int:
sub.add_parser("check", help="run detectors once and print alerts")
sub.add_parser("baseline", help="rebuild listener/SUID baselines")
sub.add_parser("list-detectors", help="list available detectors")
sub.add_parser("web", help="serve the read-only dashboard")
args = parser.parse_args(argv)
cfg = Config.load(args.config)
@ -74,6 +75,10 @@ def main(argv: list[str] | None = None) -> int:
return _cmd_baseline(cfg)
if args.cmd == "check":
return _cmd_check(cfg)
if args.cmd == "web":
from .web import serve
serve(cfg)
return 0
return _cmd_run(cfg)

View file

@ -67,9 +67,24 @@ class Config:
max_snapshots: int = 300
max_snapshot_age_days: int = 60
# notifications
# notifications — desktop
notify_users: tuple[str, ...] = ()
notify_urgency: str = "critical"
# notifications — phone push (a backend is enabled when its required keys
# are set). Only alerts at/above notify_min_severity are pushed.
notify_min_severity: str = "HIGH"
notify_ntfy_url: str = "" # e.g. "https://ntfy.sh" or self-hosted base
notify_ntfy_topic: str = "" # topic name (required to enable ntfy)
notify_ntfy_token: str = "" # optional Bearer token for protected topics
notify_pushover_token: str = "" # Pushover application token
notify_pushover_user: str = "" # Pushover user/group key
notify_webhook_url: str = "" # generic JSON POST target
dashboard_url: str = "" # optional base URL embedded in pushes
# web dashboard (read-only)
web_bind: str = "" # "" = auto-detect Tailscale IP, else explicit
web_port: int = 8787
web_token: str = "" # bearer token; auto-generated if empty + non-local
# paths
log_dir: Path = Path("/var/log/enodia-sentinel")

View file

@ -8,6 +8,7 @@ expensive filesystem-wide SUID scan is gated to its own slow cadence.
from __future__ import annotations
import json
import os
import threading
import time
from pathlib import Path
@ -124,6 +125,10 @@ class Sentinel:
# -- main loop ---------------------------------------------------------
def run(self) -> None:
self.cfg.log_dir.mkdir(parents=True, exist_ok=True)
try:
(self.cfg.log_dir / "sentinel.pid").write_text(str(os.getpid()))
except OSError:
pass
with open(self.cfg.events_log, "a") as fh:
fh.write(f"{time.strftime('%FT%T%z')} enodia-sentinel started\n")
self.build_baselines()
@ -150,6 +155,9 @@ class Sentinel:
def _start_exec_monitor(self) -> None:
if not self.cfg.ebpf_exec_monitor:
with open(self.cfg.events_log, "a") as fh:
fh.write(f"{time.strftime('%FT%T%z')} "
"eBPF exec monitor: off (disabled in config)\n")
return
from .events.monitor import ExecMonitor
self._exec_monitor = ExecMonitor(self.cfg, self._on_exec_alert)

View file

@ -0,0 +1,88 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Outbound notifications: desktop + phone push (ntfy / Pushover / webhook).
A backend is *enabled* when its required config keys are present, so turning one
on is purely a matter of configuration. Phone pushes are gated by
``notify_min_severity``. Everything sends on a worker thread and swallows its
own errors a flaky notifier must never stall or crash detection.
"""
from __future__ import annotations
import threading
from dataclasses import dataclass, field
from datetime import datetime
from ..alert import Alert, Severity
from ..config import Config
from . import backends
_SEV = {"MEDIUM": Severity.MEDIUM, "HIGH": Severity.HIGH,
"CRITICAL": Severity.CRITICAL}
_PUSH_BACKENDS = (backends.Ntfy, backends.Pushover, backends.Webhook)
@dataclass(frozen=True)
class Notification:
severity: Severity
host: str
signatures: tuple[str, ...]
sids: tuple[int, ...]
snapshot_name: str
count: int
time: str = field(default_factory=lambda: datetime.now().astimezone().isoformat())
dashboard_url: str = ""
@classmethod
def from_alerts(cls, alerts: list[Alert], host: str, snapshot_name: str,
dashboard_url: str = "") -> "Notification":
sev = max((a.severity for a in alerts), default=Severity.HIGH)
sigs = tuple(dict.fromkeys(a.signature for a in alerts))
sids = tuple(dict.fromkeys(a.sid for a in alerts if a.sid))
return cls(severity=sev, host=host, signatures=sigs, sids=sids,
snapshot_name=snapshot_name, count=len(alerts),
dashboard_url=dashboard_url)
def title(self) -> str:
return f"Enodia: {self.severity} on {self.host}"
def message(self) -> str:
lines = [f"{self.count} detection(s): {', '.join(self.signatures)}"]
if self.sids:
lines.append("sids: " + ", ".join(str(s) for s in self.sids))
lines.append(f"snapshot: {self.snapshot_name}")
if self.dashboard_url:
lines.append(self.dashboard_url.rstrip("/") + "/")
return "\n".join(lines)
def to_dict(self) -> dict:
return {
"time": self.time, "host": self.host,
"severity": str(self.severity), "signatures": list(self.signatures),
"sids": list(self.sids), "snapshot": self.snapshot_name,
"count": self.count,
}
def min_severity(cfg: Config) -> Severity:
return _SEV.get(cfg.notify_min_severity.upper(), Severity.HIGH)
def enabled_backends(cfg: Config) -> list:
return [b for b in _PUSH_BACKENDS if b.enabled(cfg)]
def dispatch(cfg: Config, notif: Notification) -> None:
"""Fire all enabled phone-push backends (on worker threads, best-effort)."""
if notif.severity < min_severity(cfg):
return
for backend in enabled_backends(cfg):
threading.Thread(target=_safe_send, args=(backend, cfg, notif),
daemon=True).start()
def _safe_send(backend, cfg: Config, notif: Notification) -> None:
try:
backend.send(cfg, notif)
except Exception:
pass

View file

@ -0,0 +1,100 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Phone-push backends. Each separates a pure ``build()`` (testable, no network)
from ``send()`` (does the request), so request construction is unit-tested
without ever hitting the wire."""
from __future__ import annotations
import json
import urllib.parse
import urllib.request
from ..alert import Severity
from ..config import Config
_TIMEOUT = 6
# Severity -> per-service priority/tags.
_NTFY_PRIORITY = {Severity.MEDIUM: "3", Severity.HIGH: "4", Severity.CRITICAL: "5"}
_NTFY_TAGS = {Severity.MEDIUM: "mag", Severity.HIGH: "warning",
Severity.CRITICAL: "rotating_light"}
_PUSHOVER_PRIORITY = {Severity.MEDIUM: "-1", Severity.HIGH: "0", Severity.CRITICAL: "1"}
def _send(req: urllib.request.Request) -> bool:
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
return 200 <= resp.status < 300
class Ntfy:
name = "ntfy"
@staticmethod
def enabled(cfg: Config) -> bool:
return bool(cfg.notify_ntfy_url and cfg.notify_ntfy_topic)
@staticmethod
def build(cfg: Config, notif) -> urllib.request.Request:
url = f"{cfg.notify_ntfy_url.rstrip('/')}/{cfg.notify_ntfy_topic}"
headers = {
"Title": notif.title(),
"Priority": _NTFY_PRIORITY.get(notif.severity, "4"),
"Tags": _NTFY_TAGS.get(notif.severity, "warning"),
}
if cfg.notify_ntfy_token:
headers["Authorization"] = f"Bearer {cfg.notify_ntfy_token}"
return urllib.request.Request(
url, data=notif.message().encode("utf-8"),
headers=headers, method="POST")
@classmethod
def send(cls, cfg: Config, notif) -> bool:
return _send(cls.build(cfg, notif))
class Pushover:
name = "pushover"
@staticmethod
def enabled(cfg: Config) -> bool:
return bool(cfg.notify_pushover_token and cfg.notify_pushover_user)
@staticmethod
def build(cfg: Config, notif) -> urllib.request.Request:
form = urllib.parse.urlencode({
"token": cfg.notify_pushover_token,
"user": cfg.notify_pushover_user,
"title": notif.title(),
"message": notif.message(),
"priority": _PUSHOVER_PRIORITY.get(notif.severity, "0"),
}).encode("utf-8")
return urllib.request.Request(
"https://api.pushover.net/1/messages.json", data=form,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST")
@classmethod
def send(cls, cfg: Config, notif) -> bool:
return _send(cls.build(cfg, notif))
class Webhook:
name = "webhook"
@staticmethod
def enabled(cfg: Config) -> bool:
return bool(cfg.notify_webhook_url)
@staticmethod
def build(cfg: Config, notif) -> urllib.request.Request:
body = json.dumps({
"title": notif.title(),
"message": notif.message(),
**notif.to_dict(),
}).encode("utf-8")
return urllib.request.Request(
cfg.notify_webhook_url, data=body,
headers={"Content-Type": "application/json"}, method="POST")
@classmethod
def send(cls, cfg: Config, notif) -> bool:
return _send(cls.build(cfg, notif))

View file

@ -220,6 +220,14 @@ def capture(alerts: list[Alert], state: SystemState, cfg: Config) -> Path:
f"{base.with_suffix('.log')} — signatures: {sigs}\n")
_notify(cfg, f"{severity}: {sigs}")
# Phone push (ntfy / Pushover / webhook), gated by notify_min_severity.
from . import notify
notif = notify.Notification.from_alerts(
alerts, host=report["host"],
snapshot_name=base.with_suffix(".log").name,
dashboard_url=cfg.dashboard_url)
notify.dispatch(cfg, notif)
return base.with_suffix(".log")

View file

@ -0,0 +1,158 @@
<!DOCTYPE html>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Enodia Sentinel</title>
<style>
:root{
--bg:#0b0e14; --panel:#11151f; --panel2:#161b27; --line:#222a3a;
--fg:#cdd6e4; --dim:#7c8aa5; --accent:#5ad1c8;
--crit:#ff5370; --high:#ffb454; --med:#73d0ff;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.5 ui-monospace,"JetBrains Mono",Menlo,Consolas,monospace}
header{display:flex;align-items:center;gap:14px;padding:14px 20px;
border-bottom:1px solid var(--line);background:var(--panel)}
header h1{font-size:16px;margin:0;letter-spacing:.18em;color:var(--accent)}
header .host{color:var(--dim)}
.dot{width:10px;height:10px;border-radius:50%;background:#444;display:inline-block}
.dot.up{background:#46d160;box-shadow:0 0 8px #46d160}
.dot.down{background:var(--crit)}
.spacer{flex:1}
.meta{color:var(--dim);font-size:12px}
.cards{display:flex;gap:12px;padding:16px 20px;flex-wrap:wrap}
.card{background:var(--panel);border:1px solid var(--line);border-radius:8px;
padding:12px 16px;min-width:120px}
.card .n{font-size:26px;font-weight:700}
.card .l{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.1em}
.card.crit .n{color:var(--crit)} .card.high .n{color:var(--high)}
.card.med .n{color:var(--med)}
main{display:grid;grid-template-columns:minmax(340px,460px) 1fr;gap:0;
border-top:1px solid var(--line);height:calc(100vh - 180px)}
.list,.detail{overflow:auto;height:100%}
.list{border-right:1px solid var(--line)}
.row{padding:10px 16px;border-bottom:1px solid var(--line);cursor:pointer}
.row:hover{background:var(--panel2)} .row.sel{background:var(--panel2);
border-left:3px solid var(--accent);padding-left:13px}
.row .top{display:flex;justify-content:space-between;gap:8px}
.sev{font-weight:700;font-size:11px;padding:1px 7px;border-radius:4px}
.sev.CRITICAL{color:var(--crit);border:1px solid var(--crit)}
.sev.HIGH{color:var(--high);border:1px solid var(--high)}
.sev.MEDIUM{color:var(--med);border:1px solid var(--med)}
.row .sigs{color:var(--fg);margin-top:4px;font-size:13px}
.row .t{color:var(--dim);font-size:11px}
.row .sids{color:var(--dim);font-size:11px}
.detail{padding:18px 22px}
.detail pre{white-space:pre-wrap;word-break:break-word;margin:0;
color:#b9c4d8;font-size:12.5px}
.empty{color:var(--dim);padding:40px;text-align:center}
.err{color:var(--crit);padding:30px;text-align:center}
footer{border-top:1px solid var(--line);background:var(--panel);
padding:8px 20px;color:var(--dim);font-size:11px;display:flex;gap:18px}
a{color:var(--accent)}
</style>
</head>
<body>
<header>
<h1>ENODIA&nbsp;SENTINEL</h1>
<span class="dot" id="dot"></span>
<span class="host" id="host"></span>
<span class="spacer"></span>
<span class="meta" id="ebpf"></span>
<span class="meta" id="ver"></span>
</header>
<div class="cards" id="cards"></div>
<main>
<div class="list" id="list"><div class="empty">loading…</div></div>
<div class="detail" id="detail"><div class="empty">Select an alert to view its forensic snapshot.</div></div>
</main>
<footer>
<span id="status">connecting…</span>
<span class="spacer" style="flex:1"></span>
<span id="refreshed"></span>
</footer>
<script>
const TOKEN = new URLSearchParams(location.search).get("token") || "";
const HDR = TOKEN ? {Authorization:"Bearer "+TOKEN} : {};
let selected = null;
async function api(path){
const r = await fetch(path, {headers:HDR});
if(r.status===401) throw new Error("unauthorized — bad or missing ?token");
if(!r.ok) throw new Error("HTTP "+r.status);
return r.json();
}
function el(tag, cls, txt){const e=document.createElement(tag);
if(cls)e.className=cls; if(txt!=null)e.textContent=txt; return e;}
async function refresh(){
try{
const st = await api("/api/status");
document.getElementById("dot").className = "dot "+(st.running?"up":"down");
document.getElementById("host").textContent = st.host || "";
document.getElementById("ver").textContent = "v"+st.version;
document.getElementById("ebpf").textContent = "eBPF: "+(st.ebpf||"?");
document.getElementById("status").textContent =
st.running ? "daemon running" : "daemon NOT running";
const c = st.counts||{};
const cards = document.getElementById("cards"); cards.innerHTML="";
for(const [lbl,key] of [["CRITICAL","crit"],["HIGH","high"],["MEDIUM","med"]]){
const card=el("div","card "+key);
card.append(el("div","n",String(c[lbl]||0))); card.append(el("div","l",lbl));
cards.append(card);
}
const tot=el("div","card"); tot.append(el("div","n",String(st.total_alerts||0)));
tot.append(el("div","l","total alerts")); cards.append(tot);
const alerts = await api("/api/alerts");
const list = document.getElementById("list"); list.innerHTML="";
if(!alerts.length){ list.append(el("div","empty","No alerts captured.")); }
for(const a of alerts){
const row=el("div","row"); row.dataset.name=a.name;
if(a.name===selected) row.classList.add("sel");
const top=el("div","top");
top.append(el("span","sev "+a.severity, a.severity));
top.append(el("span","t", (a.time||"").replace("T"," ").slice(0,19)));
row.append(top);
row.append(el("div","sigs", a.signatures.join(", ")));
if(a.sids.length) row.append(el("div","sids","sid: "+a.sids.join(", ")));
row.onclick=()=>openAlert(a.name);
list.append(row);
}
document.getElementById("refreshed").textContent =
"updated "+new Date().toLocaleTimeString();
}catch(e){
document.getElementById("status").textContent = "error: "+e.message;
if(String(e.message).includes("unauthorized"))
document.getElementById("detail").innerHTML =
'<div class="err">Unauthorized. Open this page with ?token=YOUR_TOKEN</div>';
}
}
async function openAlert(name){
selected = name;
document.querySelectorAll(".row").forEach(r=>
r.classList.toggle("sel", r.dataset.name===name));
const d = document.getElementById("detail");
d.innerHTML='<div class="empty">loading…</div>';
try{
const a = await api("/api/alerts/"+encodeURIComponent(name));
d.innerHTML="";
const pre=el("pre",null,a.text || JSON.stringify(a.json,null,2));
d.append(pre);
}catch(e){ d.innerHTML='<div class="err">'+e.message+'</div>'; }
}
refresh();
setInterval(refresh, 10000);
</script>
</body>
</html>

244
enodia_sentinel/web.py Normal file
View file

@ -0,0 +1,244 @@
# 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()