Uvicorn's h11/httptools HTTP protocols don't populate scope['extensions']['tls'], so /swarm/heartbeat's per-request cert pinning was 403ing every call despite CERT_REQUIRED validating the cert at handshake. Patch RequestResponseCycle.__init__ on both protocol modules to read the peer cert off the asyncio transport and write DER bytes into scope['extensions']['tls']['client_cert_chain']. Importing the module from swarm_api.py auto-installs the patch in the swarmctl uvicorn worker before any request is served.
73 lines
2.5 KiB
Python
73 lines
2.5 KiB
Python
"""Inject the TLS peer cert into ASGI scope — uvicorn ≤ 0.44 does not.
|
|
|
|
Uvicorn's h11/httptools HTTP protocols build the ASGI ``scope`` dict
|
|
without any ``extensions.tls`` entry, so per-request cert pinning
|
|
handlers (like POST /swarm/heartbeat) can't see the client cert that
|
|
CERT_REQUIRED already validated at handshake.
|
|
|
|
We patch ``RequestResponseCycle.__init__`` on both protocol modules to
|
|
read the peer cert off the asyncio transport (which *does* carry it)
|
|
and write the DER bytes into
|
|
``scope["extensions"]["tls"]["client_cert_chain"]``. This is the same
|
|
key the ASGI TLS extension proposal uses, so the application code will
|
|
keep working unchanged if a future uvicorn populates it natively.
|
|
|
|
Import this module once at app startup time (before uvicorn starts
|
|
accepting connections). Idempotent — subsequent imports are no-ops.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
|
|
_PATCHED = False
|
|
|
|
|
|
def _wrap_cycle_init(cycle_cls) -> None:
|
|
original = cycle_cls.__init__
|
|
|
|
def _patched_init(self, *args: Any, **kwargs: Any) -> None:
|
|
original(self, *args, **kwargs)
|
|
transport = kwargs.get("transport") or getattr(self, "transport", None)
|
|
if transport is None:
|
|
return
|
|
ssl_obj = transport.get_extra_info("ssl_object")
|
|
if ssl_obj is None:
|
|
return
|
|
try:
|
|
der = ssl_obj.getpeercert(binary_form=True)
|
|
except Exception:
|
|
return
|
|
if not der:
|
|
return
|
|
# scope is a mutable dict uvicorn stores here; Starlette forwards
|
|
# it to handlers as request.scope. Use setdefault so we don't clobber
|
|
# any future native extension entries from uvicorn itself.
|
|
scope = self.scope
|
|
extensions = scope.setdefault("extensions", {})
|
|
extensions.setdefault("tls", {"client_cert_chain": [der]})
|
|
|
|
cycle_cls.__init__ = _patched_init
|
|
|
|
|
|
def install() -> None:
|
|
"""Patch uvicorn's HTTP cycle classes. Safe to call multiple times."""
|
|
global _PATCHED
|
|
if _PATCHED:
|
|
return
|
|
try:
|
|
from uvicorn.protocols.http import h11_impl
|
|
_wrap_cycle_init(h11_impl.RequestResponseCycle)
|
|
except Exception: # nosec B110 - optional uvicorn impl may be unavailable
|
|
pass
|
|
try:
|
|
from uvicorn.protocols.http import httptools_impl
|
|
_wrap_cycle_init(httptools_impl.RequestResponseCycle)
|
|
except Exception: # nosec B110 - optional uvicorn impl may be unavailable
|
|
pass
|
|
_PATCHED = True
|
|
|
|
|
|
# Auto-install on import so simply importing this module patches uvicorn.
|
|
install()
|