360 lines
16 KiB
Python
360 lines
16 KiB
Python
"""Minimal HTTP layer for the per-tenant HUX service.
|
|
|
|
Stdlib only. A ``Router`` maps method + path template to a handler; family
|
|
modules register their routes with it. Every request resolves identity from
|
|
trusted headers, checks the owning card's flag, and writes an audit outcome.
|
|
Errors always leave as ``hux.error.v1``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import threading
|
|
import time
|
|
from collections.abc import Callable, Mapping
|
|
from dataclasses import dataclass, field
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
from hux import audit
|
|
from hux.errors import Forbidden, HuxError, Invalid, NotFound, RateLimited, TooLarge
|
|
from hux.flags import CONTRACT_VERSION, Flags, build_from_environ, worker_may_call
|
|
from hux.identity import Identity, resolve
|
|
from hux.store import TenantStore
|
|
|
|
MAX_BODY_BYTES = 1024 * 1024
|
|
MAX_QUERY_FIELDS = 64
|
|
DEFAULT_REQUEST_TIMEOUT_SECONDS = 10.0
|
|
DEFAULT_READS_PER_MINUTE = 300
|
|
DEFAULT_WRITES_PER_MINUTE = 30
|
|
MAX_RATE_BUCKETS = 4096
|
|
Handler = Callable[["Request"], "Response"]
|
|
|
|
|
|
def _bounded_number(environ: Mapping[str, str], name: str, default: float, low: float, high: float) -> float:
|
|
"""Read one numeric setting, using its safe default when malformed or outside bounds."""
|
|
try:
|
|
value = float(environ.get(name, default))
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return value if math.isfinite(value) and low <= value <= high else default
|
|
|
|
|
|
class RateLimiter:
|
|
"""Small fixed-window limiter bounded by active subjects in the current minute."""
|
|
|
|
def __init__(self, reads: int, writes: int, clock: Callable[[], float] = time.monotonic) -> None:
|
|
self.limits = {"read": reads, "write": writes}
|
|
self.clock = clock
|
|
self._windows: dict[tuple[str, str], tuple[float, int]] = {}
|
|
self._lock = threading.Lock()
|
|
|
|
def check(self, subject: str, method: str) -> int | None:
|
|
"""Consume one request and return Retry-After seconds when its bucket is full."""
|
|
now = self.clock()
|
|
bucket = "read" if method in {"GET", "HEAD", "OPTIONS"} else "write"
|
|
key = (subject, bucket)
|
|
with self._lock:
|
|
# Each accepted identity can leave at most two current entries;
|
|
# expired identities disappear whenever any request arrives.
|
|
self._windows = {item: value for item, value in self._windows.items() if value[0] > now}
|
|
reset, used = self._windows.get(key, (now + 60.0, 0))
|
|
if key not in self._windows and len(self._windows) >= MAX_RATE_BUCKETS:
|
|
return 60
|
|
if used >= self.limits[bucket]:
|
|
return max(1, math.ceil(reset - now))
|
|
self._windows[key] = (reset, used + 1)
|
|
return None
|
|
|
|
|
|
@dataclass
|
|
class Request:
|
|
"""Everything a handler needs; no raw socket access."""
|
|
|
|
method: str
|
|
path: str
|
|
params: dict[str, str]
|
|
query: dict[str, str]
|
|
headers: Mapping[str, str]
|
|
body: Any
|
|
identity: Identity
|
|
store: TenantStore
|
|
flags: Flags
|
|
flags_build: dict[str, str] = field(default_factory=dict)
|
|
|
|
def if_match(self) -> int | None:
|
|
"""Parsed If-Match revision, or None when absent."""
|
|
raw = self.header("If-Match")
|
|
if raw == "":
|
|
return None
|
|
if not raw.isdigit():
|
|
raise Invalid("If-Match must be a revision integer")
|
|
return int(raw)
|
|
|
|
def idempotency_key(self) -> str:
|
|
"""Client idempotency key, validated against the contract pattern."""
|
|
raw = self.header("Idempotency-Key")
|
|
if raw and not re.match(r"^[A-Za-z0-9._:-]{8,120}$", raw):
|
|
raise Invalid("malformed Idempotency-Key")
|
|
return raw
|
|
|
|
def header(self, name: str) -> str:
|
|
"""Case-insensitive header lookup."""
|
|
for key, value in self.headers.items():
|
|
if key.lower() == name.lower():
|
|
return value.strip()
|
|
return ""
|
|
|
|
def audit(self, action: str, resource: str, outcome: str = "allow", reason: str = "") -> None:
|
|
"""Write an audit outcome for this request."""
|
|
audit.record(self.store, self.identity, action, resource, outcome, reason)
|
|
|
|
|
|
@dataclass
|
|
class Response:
|
|
"""JSON (or SSE) response."""
|
|
|
|
status: int = 200
|
|
body: Any = None
|
|
headers: dict[str, str] = field(default_factory=dict)
|
|
stream: Callable[[], Any] | None = None
|
|
|
|
|
|
@dataclass
|
|
class Route:
|
|
"""One registered handler."""
|
|
|
|
method: str
|
|
template: str
|
|
card: str
|
|
action: str
|
|
handler: Handler
|
|
max_body: int = MAX_BODY_BYTES
|
|
pattern: re.Pattern = field(init=False)
|
|
|
|
def __post_init__(self) -> None:
|
|
regex = re.sub(r"\{(\w+)\}", r"(?P<\1>[A-Za-z0-9._:-]+)", self.template)
|
|
self.pattern = re.compile(f"^{regex}$")
|
|
|
|
|
|
class Router:
|
|
"""Route table plus the request pipeline."""
|
|
|
|
def __init__(self, data_root: Path, environ: Mapping[str, str] | None = None) -> None:
|
|
self.data_root = Path(data_root)
|
|
self.environ = dict(os.environ if environ is None else environ)
|
|
self.flags = Flags(self.environ)
|
|
self.build = build_from_environ(self.environ)
|
|
self.routes: list[Route] = []
|
|
reads = int(_bounded_number(self.environ, "HUX_READS_PER_MINUTE", DEFAULT_READS_PER_MINUTE, 1, 10_000))
|
|
writes = int(_bounded_number(self.environ, "HUX_WRITES_PER_MINUTE", DEFAULT_WRITES_PER_MINUTE, 1, 10_000))
|
|
self.request_timeout = _bounded_number(
|
|
self.environ, "HUX_REQUEST_TIMEOUT_SECONDS", DEFAULT_REQUEST_TIMEOUT_SECONDS, 0.1, 60.0
|
|
)
|
|
self.rate_limiter = RateLimiter(reads, writes)
|
|
|
|
def add(self, method: str, template: str, card: str, action: str, handler: Handler, max_body: int = MAX_BODY_BYTES) -> None:
|
|
"""Register a handler; ``action`` is the audit action name (family.verb), ``max_body`` its byte cap."""
|
|
self.routes.append(Route(method, template, card, action, handler, max_body))
|
|
|
|
def bind_capability_routes(self) -> None:
|
|
"""Make feature negotiation depend on the route templates this process really registered."""
|
|
routes: dict[str, set[str]] = {}
|
|
for route in self.routes:
|
|
routes.setdefault(route.card, set()).add(route.template)
|
|
self.flags.bind_routes(routes)
|
|
|
|
def match(self, method: str, path: str) -> tuple[Route | None, dict[str, str], bool]:
|
|
"""Return (route, params, path_known)."""
|
|
known = False
|
|
for route in self.routes:
|
|
found = route.pattern.match(path)
|
|
if found:
|
|
known = True
|
|
if route.method == method:
|
|
return route, found.groupdict(), True
|
|
return None, {}, known
|
|
|
|
def body_limit(self, method: str, raw_path: str) -> int:
|
|
"""Maximum declared body for a matching route, before a socket read allocates it."""
|
|
route, _, _ = self.match(method, urlsplit(raw_path).path)
|
|
return route.max_body if route is not None else MAX_BODY_BYTES
|
|
|
|
def dispatch(self, method: str, raw_path: str, headers: Mapping[str, str], body: bytes) -> Response:
|
|
"""Run the full pipeline and never raise."""
|
|
parts = urlsplit(raw_path)
|
|
route: Route | None = None
|
|
try:
|
|
identity = resolve(headers, self.environ)
|
|
store = TenantStore(self.data_root, identity)
|
|
except HuxError as error:
|
|
return Response(error.status, error.record())
|
|
try:
|
|
retry_after = self.rate_limiter.check(identity.subject, method)
|
|
if retry_after is not None:
|
|
raise RateLimited(retry_after)
|
|
route, params, known = self.match(method, parts.path)
|
|
if route is None:
|
|
error = Invalid("method not allowed") if known else NotFound("no such route")
|
|
audit.record(store, identity, "http.route", parts.path, "not_found", error.message)
|
|
return Response(405 if known else 404, error.record())
|
|
# SO-08: the worker allowlist is checked before the flag so a
|
|
# worker cannot even learn which cards are on.
|
|
if identity.trust == "worker" and not worker_may_call(method, route.template):
|
|
raise Forbidden("route is not available to worker trust")
|
|
if identity.trust == "evidence":
|
|
allowed = {
|
|
("GET", "/hux/v1/capabilities"),
|
|
("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases"),
|
|
("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}"),
|
|
("POST", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}/transitions"),
|
|
}
|
|
if (method, route.template) not in allowed:
|
|
raise Forbidden("route is not available to evidence trust")
|
|
self.flags.require(route.card)
|
|
try:
|
|
parsed_query = parse_qs(parts.query, max_num_fields=MAX_QUERY_FIELDS)
|
|
except ValueError as error:
|
|
raise Invalid("query has too many fields") from error
|
|
query = {key: values[-1] for key, values in parsed_query.items()}
|
|
payload = self._decode(body, route.max_body)
|
|
request = Request(method, parts.path, params, query, headers, payload, identity, store, self.flags, self.build)
|
|
response = route.handler(request)
|
|
except HuxError as error:
|
|
outcome = {"flag_off": "flag_off", "conflict": "conflict", "not_found": "not_found"}.get(error.code, "deny")
|
|
action = route.action if route is not None else "http.route"
|
|
audit.record(store, identity, action, parts.path, outcome, error.message)
|
|
headers = {"Retry-After": str(error.retry_after)} if isinstance(error, RateLimited) else {}
|
|
return Response(error.status, error.record(), headers)
|
|
except Exception: # noqa: BLE001 - the pipeline never raises; anything else is a 500 with no detail leaked
|
|
error = HuxError("internal error")
|
|
action = route.action if route is not None else "http.route"
|
|
audit.record(store, identity, action, parts.path, "deny", error.message)
|
|
return Response(error.status, error.record())
|
|
return response
|
|
|
|
@staticmethod
|
|
def _decode(body: bytes, max_body: int = MAX_BODY_BYTES) -> Any:
|
|
if not body:
|
|
return None
|
|
if len(body) > max_body:
|
|
raise TooLarge(f"body exceeds {max_body} bytes")
|
|
try:
|
|
return json.loads(body)
|
|
except json.JSONDecodeError as error:
|
|
raise Invalid(f"body is not JSON: {error.msg}") from error
|
|
|
|
|
|
def page(items: list[Any], next_cursor: Any = None) -> Response:
|
|
"""Standard list envelope."""
|
|
return Response(200, {"items": items, "next": next_cursor})
|
|
|
|
|
|
def make_handler(router: Router) -> type[BaseHTTPRequestHandler]:
|
|
"""Bind a Router to a BaseHTTPRequestHandler subclass."""
|
|
|
|
class HuxHandler(BaseHTTPRequestHandler):
|
|
server_version = "hux-foundation/1.0"
|
|
|
|
def log_message(self, fmt: str, *args: Any) -> None:
|
|
return
|
|
|
|
def _run(self) -> None:
|
|
try:
|
|
if self.headers.get("Transfer-Encoding"):
|
|
raise Invalid("Transfer-Encoding is not supported")
|
|
lengths = self.headers.get_all("Content-Length", [])
|
|
if len(lengths) > 1:
|
|
raise Invalid("duplicate Content-Length is not supported")
|
|
raw_length = lengths[0] if lengths else "0"
|
|
if not raw_length.isascii() or not raw_length.isdigit():
|
|
raise Invalid("Content-Length must be a non-negative integer")
|
|
if len(raw_length) > 10:
|
|
raise TooLarge("declared body length is too large")
|
|
length = int(raw_length)
|
|
limit = 0 if self.path == "/healthz" else router.body_limit(self.command, self.path)
|
|
if length > limit:
|
|
raise TooLarge(f"body exceeds {limit} bytes")
|
|
body = self.rfile.read(length) if length else b""
|
|
if len(body) != length:
|
|
raise Invalid("request body is incomplete or timed out")
|
|
if self.path == "/healthz":
|
|
body = {"status": "ok", "contract_version": CONTRACT_VERSION}
|
|
scheduler = getattr(router, "retention_scheduler", None)
|
|
if scheduler is not None:
|
|
body["retention"] = scheduler.health()
|
|
self._send(Response(200, body))
|
|
return
|
|
self._send(router.dispatch(self.command, self.path, dict(self.headers.items()), body))
|
|
except HuxError as error:
|
|
self._send(Response(error.status, error.record()))
|
|
except (OSError, TimeoutError):
|
|
error = Invalid("request body is incomplete or timed out")
|
|
self._send(Response(error.status, error.record()))
|
|
|
|
def _send(self, response: Response) -> None:
|
|
if response.stream is not None:
|
|
self.send_response(response.status)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
for key, value in response.headers.items():
|
|
self.send_header(key, value)
|
|
self.end_headers()
|
|
for chunk in response.stream():
|
|
self.wfile.write(chunk)
|
|
self.wfile.flush()
|
|
return
|
|
data = json.dumps(response.body, sort_keys=True).encode()
|
|
self.send_response(response.status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
for key, value in response.headers.items():
|
|
self.send_header(key, value)
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _run
|
|
|
|
return HuxHandler
|
|
|
|
|
|
class BoundedHTTPServer(ThreadingHTTPServer):
|
|
"""Threading server that bounds header and body socket reads from accept onward."""
|
|
|
|
def __init__(self, address: tuple[str, int], handler: type[BaseHTTPRequestHandler], timeout: float, scheduler: Any = None) -> None:
|
|
self.request_timeout = timeout
|
|
self.scheduler = scheduler
|
|
super().__init__(address, handler)
|
|
|
|
def get_request(self) -> tuple[Any, Any]:
|
|
"""Accept one connection and apply its per-request socket deadline."""
|
|
request, address = super().get_request()
|
|
request.settimeout(self.request_timeout)
|
|
return request, address
|
|
|
|
def server_close(self) -> None:
|
|
"""Stop background work before closing the listening socket."""
|
|
if self.scheduler is not None:
|
|
self.scheduler.stop()
|
|
super().server_close()
|
|
|
|
|
|
def serve(router: Router, host: str = "127.0.0.1", port: int = 8790) -> ThreadingHTTPServer:
|
|
"""Create (but do not start) the server; callers call serve_forever()."""
|
|
from hux.retention_scheduler import RetentionScheduler
|
|
|
|
scheduler = RetentionScheduler(router)
|
|
router.retention_scheduler = scheduler
|
|
server = BoundedHTTPServer((host, port), make_handler(router), router.request_timeout, scheduler)
|
|
server.daemon_threads = True
|
|
scheduler.start()
|
|
return server
|