168 lines
5.9 KiB
Python
168 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Concurrency-bounded threaded HTTP server for the Hermes SCM broker."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
import logging
|
|
from http.server import ThreadingHTTPServer
|
|
|
|
MAX_CONCURRENT_REQUESTS = 3
|
|
|
|
|
|
def validate_ascii_headers(headers, *, maximum_count: int, maximum_bytes: int, error) -> None:
|
|
"""Reject oversized or ambiguous broker headers before a request body is read."""
|
|
items = list(headers.items())
|
|
if len(items) > maximum_count:
|
|
raise error("SCM request has too many headers")
|
|
total = 0
|
|
for name, value in items:
|
|
if not name.isascii() or not value.isascii():
|
|
raise error("SCM request headers must be ASCII")
|
|
total += len(name) + len(value) + 4
|
|
if any(ord(character) < 32 and character != "\t" for character in value):
|
|
raise error("SCM request header contains controls")
|
|
if total > maximum_bytes:
|
|
raise error("SCM request headers exceed the safe limit")
|
|
if len(headers.get_all("Content-Length", [])) > 1:
|
|
raise error("SCM request has duplicate Content-Length")
|
|
|
|
|
|
def _rejection_category(phase: str, error: BaseException) -> str:
|
|
"""Reduce known pack-policy failures to fixed, non-sensitive categories."""
|
|
if error.__class__.__name__ != "PolicyError":
|
|
return "upstream" if error.__class__.__name__ in {"TimeoutError", "URLError"} else "io"
|
|
if phase != "pack":
|
|
return "policy"
|
|
message = str(error)
|
|
if message == "Git command does not match its task grant":
|
|
return "grant-command"
|
|
if message == "Git update does not prove fast-forward ancestry":
|
|
return "ancestry"
|
|
if message == "Git delta base is outside the pack; push full packs":
|
|
return "thin-pack"
|
|
if "runtime credential material" in message:
|
|
return "credential"
|
|
if "credential-shaped content" in message:
|
|
return "content"
|
|
if "ref command" in message or "limited to namespaced" in message:
|
|
return "ref"
|
|
if "receive-pack" in message or "Git request has" in message:
|
|
return "framing"
|
|
return "policy"
|
|
|
|
|
|
def log_rejection(phase: str, error: BaseException) -> None:
|
|
"""Log a fixed operational category without request material or error text."""
|
|
category = _rejection_category(phase, error)
|
|
logging.warning("scm_rejected phase=%s category=%s", phase, category)
|
|
|
|
|
|
class _AbsoluteDeadlineReader:
|
|
"""Read header lines against one wall-clock deadline, not idle timeouts."""
|
|
|
|
def __init__(self, stream, connection):
|
|
self._stream = stream
|
|
self._connection = connection
|
|
self._deadline: float | None = None
|
|
|
|
def begin(self, timeout: float) -> None:
|
|
self._deadline = time.monotonic() + timeout
|
|
|
|
def end(self) -> None:
|
|
self._deadline = None
|
|
|
|
def readline(self, limit: int = -1) -> bytes:
|
|
if self._deadline is None:
|
|
return self._stream.readline(limit)
|
|
value = bytearray()
|
|
while limit < 0 or len(value) < limit:
|
|
remaining = self._deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise TimeoutError("absolute SCM header deadline exceeded")
|
|
self._connection.settimeout(remaining)
|
|
character = self._stream.read(1)
|
|
if not character:
|
|
break
|
|
value.extend(character)
|
|
if character == b"\n":
|
|
break
|
|
return bytes(value)
|
|
|
|
def __getattr__(self, name: str):
|
|
return getattr(self._stream, name)
|
|
|
|
|
|
class AbsoluteHeaderDeadlineMixin:
|
|
"""Apply an absolute request-line plus header deadline to HTTP handlers."""
|
|
|
|
header_deadline_seconds = 10.0
|
|
|
|
def setup(self) -> None:
|
|
super().setup()
|
|
self._header_reader = _AbsoluteDeadlineReader(self.rfile, self.connection)
|
|
self.rfile = self._header_reader
|
|
|
|
def handle_one_request(self) -> None:
|
|
try:
|
|
self._header_reader.begin(self.header_deadline_seconds)
|
|
try:
|
|
self.raw_requestline = self.rfile.readline(65537)
|
|
if len(self.raw_requestline) > 65536:
|
|
self.requestline = ""
|
|
self.request_version = ""
|
|
self.command = ""
|
|
self.send_error(414)
|
|
return
|
|
if not self.raw_requestline:
|
|
self.close_connection = True
|
|
return
|
|
if not self.parse_request():
|
|
return
|
|
finally:
|
|
self._header_reader.end()
|
|
method_name = "do_" + self.command
|
|
if not hasattr(self, method_name):
|
|
self.send_error(501, "Unsupported method")
|
|
return
|
|
getattr(self, method_name)()
|
|
self.wfile.flush()
|
|
except TimeoutError:
|
|
self.close_connection = True
|
|
|
|
|
|
class BoundedThreadingHTTPServer(ThreadingHTTPServer):
|
|
"""Cap active handlers so large Git exchanges cannot exhaust memory."""
|
|
|
|
daemon_threads = True
|
|
block_on_close = True
|
|
request_queue_size = 16
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self._slots = threading.BoundedSemaphore(MAX_CONCURRENT_REQUESTS)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def process_request(self, request, client_address) -> None:
|
|
if not self._slots.acquire(blocking=False):
|
|
try:
|
|
request.sendall(
|
|
b"HTTP/1.1 503 Service Unavailable\r\n"
|
|
b"Connection: close\r\nContent-Length: 0\r\n\r\n"
|
|
)
|
|
finally:
|
|
self.shutdown_request(request)
|
|
return
|
|
try:
|
|
super().process_request(request, client_address)
|
|
except Exception:
|
|
self._slots.release()
|
|
self.shutdown_request(request)
|
|
raise
|
|
|
|
def process_request_thread(self, request, client_address) -> None:
|
|
try:
|
|
super().process_request_thread(request, client_address)
|
|
finally:
|
|
self._slots.release()
|