#!/usr/bin/env python3 """Loopback-only HTTP server for the CASAN Core Local Assurance Viewer.""" from __future__ import annotations import argparse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json import mimetypes import os from pathlib import Path import signal import threading from urllib.parse import parse_qs, unquote, urlparse from local_report import ( SAFE_ID, h6_report, project_profile, report_html, run_report, runs, trace_graph, ) ASSET_ROOT = Path(__file__).resolve().parents[2] / "assets" / "local-viewer" MAX_RESPONSE_BYTES = 24 * 1024 * 1024 def atomic_json(path: Path, payload: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") temporary.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") try: os.chmod(temporary, 0o600) except OSError: if os.name != "nt": raise os.replace(temporary, path) class ViewerServer(ThreadingHTTPServer): daemon_threads = True allow_reuse_address = False def __init__(self, address, root: Path, token: str): self.root = root.resolve() self.token = token super().__init__(address, ViewerHandler) class ViewerHandler(BaseHTTPRequestHandler): server_version = "CASAN-Core-Viewer/1" sys_version = "" @property def viewer(self) -> ViewerServer: return self.server # type: ignore[return-value] def log_message(self, fmt: str, *args) -> None: # Never persist the session token from the initial/query download URL. print( "%s - - [%s] %s %s" % ( self.client_address[0], self.log_date_time_string(), self.command, urlparse(self.path).path, ), flush=True, ) def _host_allowed(self) -> bool: host = self.headers.get("Host", "").split(":", 1)[0].strip("[]").lower() return host in {"127.0.0.1", "localhost", "::1"} def _authorized(self, query: dict[str, list[str]]) -> bool: supplied = self.headers.get("X-CASAN-Viewer-Token", "") if not supplied: supplied = query.get("token", [""])[0] return bool(supplied) and supplied == self.viewer.token def _headers(self, status: int, content_type: str, length: int) -> None: self.send_response(status) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(length)) self.send_header("Cache-Control", "no-store") self.send_header("Pragma", "no-cache") self.send_header("X-Content-Type-Options", "nosniff") self.send_header("X-Frame-Options", "DENY") self.send_header("Referrer-Policy", "no-referrer") self.send_header("Cross-Origin-Resource-Policy", "same-origin") self.send_header( "Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; " "img-src 'self' data:; connect-src 'self'; object-src 'none'; " "base-uri 'none'; frame-ancestors 'none'; form-action 'self'", ) self.end_headers() def _send(self, status: int, body: bytes, content_type: str) -> None: if len(body) > MAX_RESPONSE_BYTES: self._json(413, {"error": "CASAN_VIEW_RESPONSE_TOO_LARGE"}) return self._headers(status, content_type, len(body)) if self.command != "HEAD": self.wfile.write(body) def _json(self, status: int, payload) -> None: self._send( status, (json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8"), "application/json; charset=utf-8", ) def _asset(self, name: str) -> None: safe_name = "index.html" if name in {"", "/"} else name.lstrip("/") if safe_name not in {"index.html", "app.css", "app.js", "favicon.svg"}: self._json(404, {"error": "CASAN_VIEW_NOT_FOUND"}) return path = ASSET_ROOT / safe_name try: body = path.read_bytes() except OSError: self._json(503, {"error": "CASAN_VIEW_ASSET_MISSING"}) return content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream" if content_type.startswith("text/") or content_type == "application/javascript": content_type += "; charset=utf-8" self._send(200, body, content_type) def _download(self, report: dict, fmt: str, filename: str) -> None: if fmt == "json": body = (json.dumps(report, ensure_ascii=False, indent=2) + "\n").encode("utf-8") content_type = "application/json; charset=utf-8" elif fmt == "html": body = report_html(report, "h6" if report.get("harness") == "H6" else "run").encode("utf-8") content_type = "text/html; charset=utf-8" else: self._json(400, {"error": "CASAN_REPORT_FORMAT_INVALID"}) return if len(body) > MAX_RESPONSE_BYTES: self._json(413, {"error": "CASAN_REPORT_TOO_LARGE"}) return self.send_response(200) self.send_header("Content-Type", content_type) self.send_header("Content-Disposition", f'attachment; filename="{filename}.{fmt}"') self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.send_header("X-Content-Type-Options", "nosniff") self.send_header("Referrer-Policy", "no-referrer") self.end_headers() if self.command != "HEAD": self.wfile.write(body) def do_HEAD(self) -> None: self.do_GET() def do_GET(self) -> None: if self.client_address[0] not in {"127.0.0.1", "::1"} or not self._host_allowed(): self._json(403, {"error": "CASAN_VIEW_LOOPBACK_ONLY"}) return parsed = urlparse(self.path) path = unquote(parsed.path) query = parse_qs(parsed.query, keep_blank_values=False) if path in {"/", "/index.html", "/app.css", "/app.js"}: self._asset(path) return if not self._authorized(query): self._json(401, {"error": "CASAN_VIEW_TOKEN_REQUIRED"}) return try: if path == "/healthz": self._json(200, { "status": "ok", "mode": "local_core", "project_id": project_profile(self.viewer.root)["project_id"], "pid": os.getpid(), }) elif path == "/api/v1/project": self._json(200, project_profile(self.viewer.root)) elif path == "/api/v1/runs": self._json(200, runs(self.viewer.root, int(query.get("limit", ["100"])[0]))) elif path == "/api/v1/reports/h6": report = self._h6(query) self._json(200, report) elif path == "/api/v1/reports/h6/export": report = self._h6(query) self._download(report, query.get("format", ["html"])[0], report["report_id"]) elif path == "/__shutdown": self._json(200, {"status": "stopping"}) threading.Thread(target=self.viewer.shutdown, daemon=True).start() elif path.startswith("/api/v1/runs/") and path.endswith("/graph"): trace_id = path[len("/api/v1/runs/"):-len("/graph")].strip("/") self._trace_required(trace_id) self._json(200, trace_graph(self.viewer.root, trace_id)) elif path.startswith("/api/v1/reports/run/"): suffix = path[len("/api/v1/reports/run/"):].strip("/") exporting = suffix.endswith("/export") trace_id = suffix[:-len("/export")].strip("/") if exporting else suffix self._trace_required(trace_id) report = run_report(self.viewer.root, trace_id) if report["verdict"] == "not_found": self._json(404, {"error": "CASAN_RUN_NOT_FOUND"}) elif exporting: self._download( report, query.get("format", ["html"])[0], report["report_id"], ) else: self._json(200, report) else: self._json(404, {"error": "CASAN_VIEW_NOT_FOUND"}) except (TypeError, ValueError): self._json(400, {"error": "CASAN_VIEW_INVALID_QUERY"}) except BrokenPipeError: return except Exception as error: # fail closed without leaking project paths self.log_error("request failed: %s", type(error).__name__) self._json(500, {"error": "CASAN_VIEW_INTERNAL_ERROR"}) def _trace_required(self, trace_id: str) -> None: if not SAFE_ID.fullmatch(trace_id): raise ValueError("unsafe trace id") def _h6(self, query: dict[str, list[str]]) -> dict: return h6_report( self.viewer.root, project=query.get("project", [None])[0], run=query.get("run", [None])[0], from_date=query.get("from", [None])[0], to_date=query.get("to", [None])[0], limit=int(query.get("limit", ["50"])[0]), ) def do_POST(self) -> None: self._json(405, {"error": "CASAN_VIEW_READ_ONLY"}) do_PUT = do_POST do_PATCH = do_POST do_DELETE = do_POST def serve(root: Path, port: int, token: str, state_file: Path) -> int: if not token or len(token) < 32: raise ValueError("viewer token is too short") server = ViewerServer(("127.0.0.1", port), root, token) actual_port = server.server_address[1] atomic_json(state_file, { "schema_version": 1, "pid": os.getpid(), "port": actual_port, "token": token, "project_root": str(root.resolve()), "started_at": __import__("datetime").datetime.now( __import__("datetime").timezone.utc ).isoformat().replace("+00:00", "Z"), }) def stop(_signum, _frame): threading.Thread(target=server.shutdown, daemon=True).start() signal.signal(signal.SIGTERM, stop) signal.signal(signal.SIGINT, stop) try: server.serve_forever(poll_interval=0.25) finally: server.server_close() try: current = json.loads(state_file.read_text(encoding="utf-8")) if current.get("pid") == os.getpid(): state_file.unlink() except (OSError, ValueError): pass return 0 def main(argv=None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", required=True) parser.add_argument("--port", type=int, default=0) parser.add_argument("--token", required=True) parser.add_argument("--state-file", required=True) args = parser.parse_args(argv) return serve( Path(args.root).expanduser().resolve(), max(0, min(args.port, 65535)), args.token, Path(args.state_file).expanduser().resolve(), ) if __name__ == "__main__": raise SystemExit(main())