SmartestHome/admin-canvas/server.py

225 lines
8.8 KiB
Python

"""admin-canvas — the write side of the sys-admin-llm's display surface.
Companion to digest-engine/digest-web (docs/project-plan.md Phase 12), but for ad hoc
content instead of a scheduled synthesis run: statistics, power-consumption readings,
charts, snapshots — anything Home Assistant's tool-calling LLM wants to put on a thin
client's admin canvas (Phase 13), on demand rather than 4x/day.
SECURITY BOUNDARY. This is a real network listener — unlike digest-engine, which is a
oneshot batch job with no listening socket at all. It must stay reachable only from
the container host's internal Docker network (HA and nothing else — no `ports:` entry
in the compose block that starts this service), and every request additionally
requires the bearer token below, as defense in depth in case that network boundary is
ever loosened by mistake. This module knows nothing about Home Assistant, tool calls,
or the LLM that ultimately triggers a write — it only ever validates and stores what
it is given. Reads are somebody else's job entirely: admin-web (nginx, read-only,
LAN-published) serves output/, exactly mirroring how digest-web serves digest-engine's
output/ — this process never serves a GET.
Two endpoints, both POST, both token-gated:
- /show JSON {"windows": [...]}, overwrites output/latest.json
- /media/<name> raw bytes, written to output/media/<name>
See admin-canvas/README.md for the window schema (stat/image/video/chart kinds) and a
worked example of the intended HA-side `rest_command` -> here call.
"""
from __future__ import annotations
import json
import logging
import os
import re
import sys
import threading
from datetime import datetime, timezone
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit
LOG = logging.getLogger("admin-canvas")
OUTPUT_DIR = Path(os.environ.get("ADMIN_CANVAS_OUTPUT_DIR", "/output"))
MEDIA_DIR = OUTPUT_DIR / "media"
# Fails closed: an unset token means every request is rejected, never "auth is
# optional". See admin-canvas.env.example.
TOKEN = os.environ.get("ADMIN_CANVAS_TOKEN", "")
MAX_SHOW_BYTES = int(os.environ.get("ADMIN_CANVAS_MAX_SHOW_KB", "256")) * 1024
MAX_MEDIA_BYTES = int(os.environ.get("ADMIN_CANVAS_MAX_MEDIA_MB", "25")) * 1024 * 1024
# `kind` may also be omitted entirely for plain markdown-ish content — handled
# client-side by DigestWindow.open exactly as a digest window is today.
KNOWN_KINDS = {"stat", "image", "video", "chart"}
# No leading "/", no "..", no scheme — a relative path under the media/ directory
# this service itself writes to, and nothing else. Mirrors the "a payload never
# becomes a URL host" invariant thinclient_agent/mqtt_discovery.py documents for the
# thin client's own control surface; the trust boundary just lives here instead,
# since this is the component that now decides what a browser is told to fetch.
SRC_RE = re.compile(r"^media/[A-Za-z0-9_.-]+$")
MEDIA_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
MEDIA_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".mp4", ".webm"}
_write_lock = threading.Lock()
def validate_show_payload(payload) -> str | None:
"""Returns an error string, or None if payload is acceptable."""
if not isinstance(payload, dict):
return "body must be a JSON object"
windows = payload.get("windows")
if not isinstance(windows, list):
return "'windows' must be a list"
for i, win in enumerate(windows):
if not isinstance(win, dict):
return f"windows[{i}] must be an object"
kind = win.get("kind")
if kind is not None and kind not in KNOWN_KINDS:
return (
f"windows[{i}].kind {kind!r} is not one of {sorted(KNOWN_KINDS)} "
"(omit 'kind' entirely for plain text/markdown-ish content)"
)
if kind in ("image", "video"):
content = win.get("content")
src = content.get("src") if isinstance(content, dict) else None
if not isinstance(src, str) or not SRC_RE.match(src):
return (
f"windows[{i}].content.src must be a 'media/<filename>' path "
"already uploaded via POST /media/<filename>"
)
return None
class Handler(BaseHTTPRequestHandler):
server_version = "admin-canvas/1"
def log_message(self, format, *args): # noqa: A002 - must match BaseHTTPRequestHandler's signature
LOG.info("%s - %s", self.address_string(), format % args)
def _authorized(self) -> bool:
if not TOKEN:
return False
return self.headers.get("Authorization", "") == f"Bearer {TOKEN}"
def _respond(self, status: HTTPStatus, payload: dict) -> None:
body = json.dumps(payload).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 _read_body(self, max_bytes: int) -> bytes:
try:
length = int(self.headers.get("Content-Length") or 0)
except ValueError:
raise ValueError("missing or invalid Content-Length") from None
if length <= 0:
return b""
if length > max_bytes:
raise ValueError(f"body too large ({length} > {max_bytes} bytes)")
return self.rfile.read(length)
def do_GET(self): # noqa: N802 - stdlib method name
self._respond(
HTTPStatus.NOT_FOUND,
{"error": "admin-canvas only accepts writes; reads are served by admin-web"},
)
def do_POST(self): # noqa: N802 - stdlib method name
if not self._authorized():
self._respond(HTTPStatus.UNAUTHORIZED, {"error": "missing or invalid bearer token"})
return
path = urlsplit(self.path).path
if path == "/show":
self._handle_show()
elif path.startswith("/media/"):
self._handle_media(path[len("/media/"):])
else:
self._respond(HTTPStatus.NOT_FOUND, {"error": "no such endpoint"})
def _handle_show(self) -> None:
try:
raw = self._read_body(MAX_SHOW_BYTES)
except ValueError as exc:
self._respond(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": str(exc)})
return
try:
payload = json.loads(raw or b"{}")
except ValueError:
self._respond(HTTPStatus.BAD_REQUEST, {"error": "body is not valid JSON"})
return
error = validate_show_payload(payload)
if error:
self._respond(HTTPStatus.BAD_REQUEST, {"error": error})
return
payload["generated_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
with _write_lock:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
tmp = OUTPUT_DIR / "latest.json.tmp"
tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
tmp.replace(OUTPUT_DIR / "latest.json")
window_count = len(payload.get("windows", []))
LOG.info("wrote %d window(s) to output/latest.json", window_count)
self._respond(HTTPStatus.OK, {"ok": True, "windows": window_count})
def _handle_media(self, name: str) -> None:
if not MEDIA_NAME_RE.match(name) or Path(name).suffix.lower() not in MEDIA_EXTENSIONS:
self._respond(
HTTPStatus.BAD_REQUEST,
{"error": "invalid media filename — see admin-canvas/README.md for the allowed pattern"},
)
return
try:
data = self._read_body(MAX_MEDIA_BYTES)
except ValueError as exc:
self._respond(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": str(exc)})
return
if not data:
self._respond(HTTPStatus.BAD_REQUEST, {"error": "empty body"})
return
with _write_lock:
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
(MEDIA_DIR / name).write_bytes(data)
LOG.info("wrote media/%s (%d bytes)", name, len(data))
self._respond(HTTPStatus.OK, {"ok": True, "path": f"media/{name}"})
def main() -> int:
logging.basicConfig(
level=os.environ.get("LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
if not TOKEN:
LOG.error(
"ADMIN_CANVAS_TOKEN is not set — every request will be rejected until it is. "
"See admin-canvas.env.example."
)
port = int(os.environ.get("ADMIN_CANVAS_PORT", "8092"))
server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
LOG.info("admin-canvas listening on :%d (output dir: %s)", port, OUTPUT_DIR)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
return 0
if __name__ == "__main__":
sys.exit(main())