#!/usr/bin/env python3 """Install the pinned, same-origin HUX BFF into Hermes WebUI.""" from __future__ import annotations import hashlib import hmac import http.client import json import os from pathlib import Path import re import socket import stat import time from urllib.parse import parse_qsl, urlencode PINNED_UPSTREAM_COMMIT = "7a94e34a6d639576576baa9131acf6765f6d2b98" UPSTREAM_HOST = "127.0.0.1" UPSTREAM_PORT = 8790 RELAY_KEY_FILE = Path("/run/hermes-webui-hux/relay-key") CONTEXT_KEY_FILE = Path("/run/hermes-hux-context/context-key") MAX_REQUEST_BYTES = 1024 * 1024 MAX_RESPONSE_BYTES = 4 * 1024 * 1024 MAX_ARTIFACT_REQUEST_BYTES = 34 * 1024 * 1024 MAX_ARTIFACT_RESPONSE_BYTES = 52 * 1024 * 1024 MAX_STREAM_BYTES = 16 * 1024 * 1024 UPSTREAM_TIMEOUT = 10.0 STREAM_DEADLINE = 65.0 CHUNK_BYTES = 64 * 1024 SAFE_PATH = re.compile(r"^/hux/v1(?:/[A-Za-z0-9._:-]+)*$") SAFE_QUERY_KEY = re.compile(r"^[A-Za-z0-9._:@+-]{1,120}$") SAFE_QUERY_VALUE = re.compile(r"^[A-Za-z0-9 ._:@+,-]{0,240}$") SAFE_SLOT = re.compile(r"^slot-[0-9]{1,3}$") SAFE_SUBJECT = re.compile(r"^usr_[0-9a-f]{64}$") SAFE_KEY = re.compile(r"^[A-Za-z0-9._:-]{32,256}$") SAFE_IDEMPOTENCY = re.compile(r"^[A-Za-z0-9._:-]{8,120}$") SAFE_REVISION = re.compile(r"^[0-9]{1,10}$") SAFE_EVENT_ID = re.compile(r"^[0-9]{1,20}$") MUTATIONS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) CONTEXT_KEY_BYTES = 32 class BffError(Exception): """A bounded, user-safe proxy rejection.""" def __init__(self, status: int, message: str): super().__init__(message) self.status = status def _header_values(handler, name: str) -> list[str]: headers = getattr(handler, "headers", None) if headers is None: return [] if hasattr(headers, "get_all"): return [str(value) for value in (headers.get_all(name) or [])] value = headers.get(name) return [] if value is None else [str(value)] def _one_header(handler, name: str) -> str: values = _header_values(handler, name) if len(values) > 1: raise BffError(400, f"Duplicate {name} header") return values[0].strip() if values else "" def _trusted_identity(handler) -> tuple[str, str, str]: from api.auth import ensure_trusted_auth_session, parse_cookie, verify_session cookie = parse_cookie(handler) if not cookie or not verify_session(cookie): raise BffError(401, "Trusted WebUI session required") info = ensure_trusted_auth_session(handler) slot = _one_header(handler, "X-Hermes-Tenant-Identity") supplied_subject = _one_header(handler, "X-Hux-Subject") username = str((info or {}).get("username") or "") if (not info or info.get("auth_type") != "trusted" or not SAFE_SLOT.fullmatch(slot) or username != slot): raise BffError(401, "Trusted WebUI session required") subject = _derived_subject(slot) if supplied_subject and supplied_subject != subject: raise BffError(401, "Trusted WebUI session required") return slot, subject, "chat" def _context_key_path() -> Path: raw = os.environ.get("HUX_CONTEXT_KEY_FILE", "").strip() path = Path(raw) if raw else CONTEXT_KEY_FILE if not path.is_absolute() or ".." in path.parts: raise BffError(503, "HUX identity is unavailable") return path def _secure_context_key() -> bytes: path = _context_key_path() try: parent = path.parent.stat(follow_symlinks=False) if not stat.S_ISDIR(parent.st_mode) or parent.st_uid != os.geteuid(): raise BffError(503, "HUX identity is unavailable") descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)) try: info = os.fstat(descriptor) if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1 or info.st_size != CONTEXT_KEY_BYTES): raise BffError(503, "HUX identity is unavailable") key = os.read(descriptor, CONTEXT_KEY_BYTES + 1) finally: os.close(descriptor) except BffError: raise except OSError as exc: raise BffError(503, "HUX identity is unavailable") from exc if len(key) != CONTEXT_KEY_BYTES: raise BffError(503, "HUX identity is unavailable") return key def _derived_subject(slot: str) -> str: digest = hmac.new(_secure_context_key(), b"hux.subject.id.v1\0" + slot.encode("ascii"), hashlib.sha256).hexdigest() subject = "usr_" + digest if not SAFE_SUBJECT.fullmatch(subject): # defensive parity with the public identity contract raise BffError(503, "HUX identity is unavailable") return subject def _require_csrf(handler, cookie: str) -> None: from api.auth import CSRF_HEADER_NAME, verify_csrf_token supplied = _one_header(handler, CSRF_HEADER_NAME) if not supplied or not verify_csrf_token(cookie, supplied): raise BffError(403, "Valid WebUI CSRF token required") def _secure_relay_key() -> str: parent = RELAY_KEY_FILE.parent try: parent_stat = parent.stat(follow_symlinks=False) except OSError as exc: raise BffError(503, "HUX relay is unavailable") from exc if not stat.S_ISDIR(parent_stat.st_mode) or parent_stat.st_uid != os.geteuid() or parent_stat.st_mode & 0o077: raise BffError(503, "HUX relay is unavailable") flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) try: descriptor = os.open(RELAY_KEY_FILE, flags) try: file_stat = os.fstat(descriptor) if (not stat.S_ISREG(file_stat.st_mode) or file_stat.st_uid != os.geteuid() or stat.S_IMODE(file_stat.st_mode) != 0o400 or file_stat.st_nlink != 1): raise BffError(503, "HUX relay is unavailable") raw = os.read(descriptor, 257) finally: os.close(descriptor) except BffError: raise except OSError as exc: raise BffError(503, "HUX relay is unavailable") from exc try: key = raw.decode("ascii").strip() except UnicodeDecodeError as exc: raise BffError(503, "HUX relay is unavailable") from exc if len(raw) > 256 or not SAFE_KEY.fullmatch(key): raise BffError(503, "HUX relay is unavailable") return key def _safe_target(parsed) -> str: path = str(getattr(parsed, "path", "") or "") segments = path.split("/")[3:] if not SAFE_PATH.fullmatch(path) or any(segment in {".", ".."} for segment in segments): raise BffError(400, "Invalid HUX path") raw_query = str(getattr(parsed, "query", "") or "") if len(raw_query) > 4096: raise BffError(400, "Invalid HUX query") try: pairs = parse_qsl(raw_query, keep_blank_values=True, strict_parsing=True, max_num_fields=64) except ValueError as exc: raise BffError(400, "Invalid HUX query") from exc if len({key for key, _ in pairs}) != len(pairs) or any( not SAFE_QUERY_KEY.fullmatch(key) or not SAFE_QUERY_VALUE.fullmatch(value) for key, value in pairs ): raise BffError(400, "Invalid HUX query") query = urlencode(pairs) return path + ("?" + query if query else "") def _request_limit(target: str, method: str) -> int: path = target.split("?", 1)[0] artifact_write = re.fullmatch( r"/hux/v1/artifacts(?:/[A-Za-z0-9._:-]+/versions)?", path ) return MAX_ARTIFACT_REQUEST_BYTES if method == "POST" and artifact_write else MAX_REQUEST_BYTES def _response_limit(target: str) -> int: path = target.split("?", 1)[0] artifact_read = re.fullmatch( r"/hux/v1/artifacts/[A-Za-z0-9._:-]+/versions/[0-9]+(?:/diff)?", path ) return MAX_ARTIFACT_RESPONSE_BYTES if artifact_read else MAX_RESPONSE_BYTES def _read_request(handler, method: str, limit: int = MAX_REQUEST_BYTES) -> bytes | None: if _one_header(handler, "Transfer-Encoding"): raise BffError(400, "Chunked HUX requests are not supported") raw_length = _one_header(handler, "Content-Length") if not raw_length: return None if not raw_length.isdigit(): raise BffError(400, "Invalid Content-Length") length = int(raw_length) if length > limit: raise BffError(413, "HUX request body is too large") if method == "GET" and length: raise BffError(400, "GET request body is not allowed") if length and _one_header(handler, "Content-Type").lower() != "application/json": raise BffError(415, "HUX mutations require application/json") body = bytearray() while len(body) < length: chunk = handler.rfile.read(length - len(body)) if not chunk: raise BffError(400, "Incomplete HUX request body") body.extend(chunk) return bytes(body) def _validated_passthrough_headers(handler, method: str, body: bytes | None) -> dict[str, str]: headers = {"Accept": "text/event-stream" if str(handler.path).split("?", 1)[0].endswith("/stream") else "application/vnd.hermes.hux+json; version=1"} validators = (("If-Match", SAFE_REVISION), ("Idempotency-Key", SAFE_IDEMPOTENCY), ("Last-Event-ID", SAFE_EVENT_ID)) for name, pattern in validators: value = _one_header(handler, name) if value: if not pattern.fullmatch(value) or (name == "Last-Event-ID" and method != "GET"): raise BffError(400, f"Invalid {name} header") headers[name] = value if body is not None: headers["Content-Type"] = "application/json" return headers def _send_headers(handler, status: int, content_type: str, length: int | None = None) -> None: handler.send_response(status) handler.send_header("Content-Type", content_type) if length is not None: handler.send_header("Content-Length", str(length)) handler.send_header("Cache-Control", "no-store") handler.send_header("X-Content-Type-Options", "nosniff") handler.send_header("Referrer-Policy", "no-referrer") def _send_error(handler, error: BffError) -> bool: body = json.dumps({"error": str(error)}, separators=(",", ":")).encode("utf-8") _send_headers(handler, error.status, "application/json", len(body)) handler.end_headers() handler.wfile.write(body) return True def _response_type(response) -> str: content_type = str(response.getheader("Content-Type") or "application/json").split(";", 1)[0].strip().lower() if content_type not in {"application/json", "application/vnd.hermes.hux+json", "text/event-stream"}: raise BffError(502, "HUX returned an unsupported response") return content_type def _relay_stream(handler, response, content_type: str) -> bool: _send_headers(handler, response.status, content_type) handler.send_header("Connection", "close") handler.end_headers() handler.close_connection = True total = 0 deadline = time.monotonic() + STREAM_DEADLINE while time.monotonic() < deadline: chunk = response.read(min(CHUNK_BYTES, MAX_STREAM_BYTES - total + 1)) if not chunk: break total += len(chunk) if total > MAX_STREAM_BYTES: break handler.wfile.write(chunk) handler.wfile.flush() return True def _relay_response(handler, response, limit: int = MAX_RESPONSE_BYTES) -> bool: if 300 <= response.status < 400: raise BffError(502, "HUX redirects are refused") content_type = _response_type(response) if content_type == "text/event-stream": return _relay_stream(handler, response, content_type) body = response.read(limit + 1) if len(body) > limit: raise BffError(502, "HUX response is too large") _send_headers(handler, response.status, content_type, len(body)) etag = response.getheader("ETag") if etag and re.fullmatch(r'"?[0-9]{1,10}"?', etag): handler.send_header("ETag", etag) if str(response.getheader("HUX-Replayed") or "").lower() == "true": handler.send_header("HUX-Replayed", "true") audit_stale = str(response.getheader("HUX-Audit-Stale") or "").lower() if audit_stale in {"true", "false"}: handler.send_header("HUX-Audit-Stale", audit_stale) if str(response.getheader("Content-Disposition") or "").lower() == "attachment": handler.send_header("Content-Disposition", "attachment") handler.end_headers() handler.wfile.write(body) return True def proxy_hux(handler, parsed, method: str) -> bool: """Proxy one authenticated WebUI request to the fixed loopback HUX service.""" connection = None try: method = str(method or "").upper() if method != "GET" and method not in MUTATIONS: raise BffError(405, "HUX method is not allowed") slot, subject, surface = _trusted_identity(handler) from api.auth import parse_cookie if method in MUTATIONS: _require_csrf(handler, parse_cookie(handler) or "") target = _safe_target(parsed) body = _read_request(handler, method, _request_limit(target, method)) headers = _validated_passthrough_headers(handler, method, body) headers.update({"X-Hermes-Tenant-Identity": slot, "X-Hux-Subject": subject, "X-Hux-Surface": surface, "X-Hux-Trust": "relay", "X-Hux-Relay-Key": _secure_relay_key()}) connection = http.client.HTTPConnection(UPSTREAM_HOST, UPSTREAM_PORT, timeout=UPSTREAM_TIMEOUT) connection.request(method, target, body=body, headers=headers) return _relay_response(handler, connection.getresponse(), _response_limit(target)) except BffError as error: return _send_error(handler, error) except (TimeoutError, socket.timeout, ConnectionError, OSError, http.client.HTTPException): return _send_error(handler, BffError(502, "HUX service is unavailable")) finally: if connection is not None: connection.close() def _patched_routes(source: str) -> str: if "from api.hux_bff import proxy_hux" in source: raise SystemExit("Hermes HUX BFF patch is already installed") replacements = [] for method in ("GET", "POST", "PUT", "PATCH", "DELETE"): before = (f"def handle_{method.lower()}(handler, parsed) -> bool:\n" f" \"\"\"Handle all {method} routes. Returns True if handled, False for 404.\"\"\"\n") after = before + (" if parsed.path == \"/hux/v1\" or parsed.path.startswith(\"/hux/v1/\"):\n" " from api.hux_bff import proxy_hux\n" f" return proxy_hux(handler, parsed, \"{method}\")\n") if source.count(before) != 1: raise SystemExit(f"Hermes HUX BFF patch context changed: {before[:80]!r}") replacements.append((before, after)) for before, after in replacements: source = source.replace(before, after, 1) return source def apply(root: Path | None = None) -> None: """Install this module and patch all five pinned WebUI route dispatchers.""" root = root or Path(os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/opt/hermes-webui")) api_dir = root / "api" routes = api_dir / "routes.py" installed = api_dir / "hux_bff.py" patched = _patched_routes(routes.read_text(encoding="utf-8")) installed.write_text(Path(__file__).read_text(encoding="utf-8"), encoding="utf-8") routes.write_text(patched, encoding="utf-8") if __name__ == "__main__": apply()