mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(ssl): upstream httpx client inherits SSL_CERT_FILE, REQUESTS_CA_BUNDLE, NODE_EXTRA_CA_CERTS (#745)
Closes #741 ## What Headroom is commonly deployed alongside Claude Code, which sets `NODE_EXTRA_CA_CERTS` to a custom CA bundle for corporate or internal CAs. Node.js inherits this automatically; Python's `httpx` does not. Every upstream request silently failed with `SSL: CERTIFICATE_VERIFY_FAILED`, causing 502s and retry loops in the client. ## Changes - New `headroom/proxy/ssl_context.py` with `build_ssl_context()` helper that checks `SSL_CERT_FILE` → `REQUESTS_CA_BUNDLE` → `NODE_EXTRA_CA_CERTS` (first match wins) and builds an `ssl.SSLContext` with the custom CA bundle loaded - `HeadroomProxy.start()` calls `build_ssl_context()` and passes the result as `verify=` to `httpx.AsyncClient`; falls back to `verify=True` (default httpx behaviour) when no env var is set - Logs which env var and path was used at `INFO` level; warns on set-but-missing paths - 10 unit tests covering: no env var → `None`, each var returns `SSLContext`, priority order, nonexistent paths skipped ## Priority order 1. `SSL_CERT_FILE` — standard POSIX/Python ssl override 2. `REQUESTS_CA_BUNDLE` — standard requests/httpx convention 3. `NODE_EXTRA_CA_CERTS` — Node.js / Claude Code convention
This commit is contained in:
parent
ec7d0065cc
commit
e50fbb3e0d
3 changed files with 218 additions and 0 deletions
|
|
@ -142,6 +142,7 @@ from headroom.proxy.prometheus_metrics import PrometheusMetrics # noqa: F401
|
|||
from headroom.proxy.rate_limiter import TokenBucketRateLimiter # noqa: F401
|
||||
from headroom.proxy.request_logger import RequestLogger # noqa: F401
|
||||
from headroom.proxy.semantic_cache import SemanticCache # noqa: F401
|
||||
from headroom.proxy.ssl_context import find_ca_bundle
|
||||
from headroom.proxy.warmup import WarmupRegistry
|
||||
from headroom.proxy.ws_session_registry import WebSocketSessionRegistry
|
||||
from headroom.subscription.base import get_quota_registry, reset_quota_registry
|
||||
|
|
@ -871,6 +872,7 @@ class HeadroomProxy(
|
|||
operation="proxy.startup",
|
||||
metadata={"port": self.config.port, "host": self.config.host},
|
||||
)
|
||||
_ca_bundle = find_ca_bundle()
|
||||
self.http_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(
|
||||
connect=self.config.connect_timeout_seconds,
|
||||
|
|
@ -883,6 +885,7 @@ class HeadroomProxy(
|
|||
max_keepalive_connections=self.config.max_keepalive_connections,
|
||||
),
|
||||
http2=self.config.http2,
|
||||
verify=_ca_bundle if _ca_bundle is not None else True,
|
||||
)
|
||||
logger.info("Headroom Proxy started")
|
||||
logger.info(f"Optimization: {'ENABLED' if self.config.optimize else 'DISABLED'}")
|
||||
|
|
|
|||
55
headroom/proxy/ssl_context.py
Normal file
55
headroom/proxy/ssl_context.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""SSL context builder for the Headroom upstream httpx client.
|
||||
|
||||
Respects the standard CA-bundle environment variables used by Python
|
||||
(``SSL_CERT_FILE``), requests (``REQUESTS_CA_BUNDLE``), and Node.js /
|
||||
Claude Code (``NODE_EXTRA_CA_CERTS``) so that enterprise / corporate
|
||||
deployments with custom certificate authorities work without extra
|
||||
configuration.
|
||||
|
||||
Priority order (first match wins):
|
||||
1. ``SSL_CERT_FILE``
|
||||
2. ``REQUESTS_CA_BUNDLE``
|
||||
3. ``NODE_EXTRA_CA_CERTS``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger("headroom.proxy")
|
||||
|
||||
_CA_BUNDLE_ENV_VARS = (
|
||||
"SSL_CERT_FILE",
|
||||
"REQUESTS_CA_BUNDLE",
|
||||
"NODE_EXTRA_CA_CERTS",
|
||||
)
|
||||
|
||||
|
||||
def find_ca_bundle() -> str | None:
|
||||
"""Return the CA bundle path if any CA-bundle env var points to a file.
|
||||
|
||||
Iterates ``SSL_CERT_FILE``, ``REQUESTS_CA_BUNDLE``, and
|
||||
``NODE_EXTRA_CA_CERTS`` in that order. The first variable that is set
|
||||
*and* points to an existing file is returned as a string path so that
|
||||
httpx can build its own SSL context (with correct ALPN setup for HTTP/2).
|
||||
|
||||
Returns ``None`` when no env var is set (or all paths are missing),
|
||||
which signals to the caller to use httpx's default TLS verification.
|
||||
"""
|
||||
for var in _CA_BUNDLE_ENV_VARS:
|
||||
path = os.environ.get(var)
|
||||
if path and os.path.isfile(path):
|
||||
logger.info(
|
||||
"event=ssl_ca_bundle_loaded env_var=%s path=%s",
|
||||
var,
|
||||
path,
|
||||
)
|
||||
return path
|
||||
if path and not os.path.isfile(path):
|
||||
logger.warning(
|
||||
"event=ssl_ca_bundle_missing env_var=%s path=%r (skipped)",
|
||||
var,
|
||||
path,
|
||||
)
|
||||
return None
|
||||
160
tests/test_ssl_context.py
Normal file
160
tests/test_ssl_context.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""Unit tests for headroom.proxy.ssl_context.find_ca_bundle.
|
||||
|
||||
Covers:
|
||||
- Returns None when no env var is set
|
||||
- Returns a path string when SSL_CERT_FILE points to a valid PEM file
|
||||
- Returns a path string when REQUESTS_CA_BUNDLE points to a valid PEM file
|
||||
- Returns a path string when NODE_EXTRA_CA_CERTS points to a valid PEM file
|
||||
- Priority order: SSL_CERT_FILE beats REQUESTS_CA_BUNDLE beats NODE_EXTRA_CA_CERTS
|
||||
- Nonexistent paths are skipped (returns None if all paths are missing)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from headroom.proxy.ssl_context import find_ca_bundle
|
||||
|
||||
# Minimal self-signed CA certificate (PEM) used only to verify that
|
||||
# load_verify_locations accepts the file. Generated offline; never used
|
||||
# for real TLS handshakes in these tests.
|
||||
_SELF_SIGNED_CA_PEM = b"""\
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDFzCCAf+gAwIBAgIUWP49K8QzU5B68/BZSmeqPCDaBoQwDQYJKoZIhvcNAQEL
|
||||
BQAwGzEZMBcGA1UEAwwQaGVhZHJvb20tdGVzdC1jYTAeFw0yNjA2MDgxNDIwMzFa
|
||||
Fw0zNjA2MDUxNDIwMzFaMBsxGTAXBgNVBAMMEGhlYWRyb29tLXRlc3QtY2EwggEi
|
||||
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvTqYZXAhet9yw1n4cFeC8HosC
|
||||
1Od/bibXyW7ko7aOuuzUT7B9l7MwDfgrE2mjHecoSe2qbknFcv6hxbYojh4J7C8r
|
||||
UPgCA2QTtU3pBxQdwO156YAOmFPuBFPb19NAErOVlnHCU+NXCVSsE5y+AJjM161S
|
||||
W0HnZgO8OADZHBs5jSAGDE3ymMw+8xpuvRKJnuvK0Tcu6bOqOTMbnggwmPBZBBLW
|
||||
PrurPTN0vV9C2oyHA1tXgEJyYtEPoMfaqyE80GxYeUujt9EQWrLp+3k8ufB/yJ1b
|
||||
DaSrH0GZYx2HUn0p1mqWzXcKZrSrL1o+38gCmCivG0movXt6z1tUly8mTGz/AgMB
|
||||
AAGjUzBRMB0GA1UdDgQWBBTyJ8OWE/bpWbKM3SB52P+9DhGN/TAfBgNVHSMEGDAW
|
||||
gBTyJ8OWE/bpWbKM3SB52P+9DhGN/TAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
|
||||
DQEBCwUAA4IBAQAb44h2gg9wWU5todvwSXVAlBb/WZD1l/NG2PeTsGoH7xqmfgq9
|
||||
DxV6tvoIuDlu6OKz071ljSqRh0Mesh1ma1cj6snsc/jqgsakSlcOpOCsrTCvw2DB
|
||||
2oTztHnO4PiZAPtuKiawhVQpJfEna9/xOkbalazecSGngtSzd/oIJEXe299hE1/1
|
||||
Tfx2hBGZ0UogmREaXFi099rmaueZ0HIBn51b3kYqc7of5TI0fHwSHF4GdXXs2OZi
|
||||
6EVQWhKx5nQbklTYP5/ge9olEIsMdGqJEiz7WfSC6QBBgvoYyH596GiSGRZcX67p
|
||||
kF9agIt8Q8t/2kviMn2roInGTwTyPYOEQV0m
|
||||
-----END CERTIFICATE-----
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ca_pem_file(tmp_path):
|
||||
"""Write the self-signed CA PEM to a temp file and return its path."""
|
||||
p = tmp_path / "ca.pem"
|
||||
p.write_bytes(_SELF_SIGNED_CA_PEM)
|
||||
return str(p)
|
||||
|
||||
|
||||
def _clean_env(monkeypatch):
|
||||
"""Remove all three CA-bundle env vars so tests start from a clean state."""
|
||||
for var in ("SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
|
||||
class TestFindCaBundleNoEnvVars:
|
||||
def test_returns_none_when_no_env_var_set(self, monkeypatch):
|
||||
_clean_env(monkeypatch)
|
||||
assert find_ca_bundle() is None
|
||||
|
||||
|
||||
class TestFindCaBundleWithValidPem:
|
||||
def test_ssl_cert_file_returns_path(self, monkeypatch, ca_pem_file):
|
||||
_clean_env(monkeypatch)
|
||||
monkeypatch.setenv("SSL_CERT_FILE", ca_pem_file)
|
||||
ctx = find_ca_bundle()
|
||||
assert isinstance(ctx, str)
|
||||
assert os.path.isfile(ctx)
|
||||
|
||||
def test_requests_ca_bundle_returns_path(self, monkeypatch, ca_pem_file):
|
||||
_clean_env(monkeypatch)
|
||||
monkeypatch.setenv("REQUESTS_CA_BUNDLE", ca_pem_file)
|
||||
ctx = find_ca_bundle()
|
||||
assert isinstance(ctx, str)
|
||||
assert os.path.isfile(ctx)
|
||||
|
||||
def test_node_extra_ca_certs_returns_path(self, monkeypatch, ca_pem_file):
|
||||
_clean_env(monkeypatch)
|
||||
monkeypatch.setenv("NODE_EXTRA_CA_CERTS", ca_pem_file)
|
||||
ctx = find_ca_bundle()
|
||||
assert isinstance(ctx, str)
|
||||
assert os.path.isfile(ctx)
|
||||
|
||||
|
||||
class TestFindCaBundlePriority:
|
||||
def test_ssl_cert_file_beats_requests_ca_bundle(self, monkeypatch, tmp_path):
|
||||
"""SSL_CERT_FILE is used first even when REQUESTS_CA_BUNDLE is also set."""
|
||||
_clean_env(monkeypatch)
|
||||
# Two distinct files so we can identify which was loaded.
|
||||
pem1 = tmp_path / "first.pem"
|
||||
pem2 = tmp_path / "second.pem"
|
||||
pem1.write_bytes(_SELF_SIGNED_CA_PEM)
|
||||
pem2.write_bytes(_SELF_SIGNED_CA_PEM)
|
||||
|
||||
monkeypatch.setenv("SSL_CERT_FILE", str(pem1))
|
||||
monkeypatch.setenv("REQUESTS_CA_BUNDLE", str(pem2))
|
||||
|
||||
# Both files are valid; we cannot easily inspect which CA was loaded
|
||||
# into the context, but we can verify the function returns a path
|
||||
# (not None) and that it is tied to SSL_CERT_FILE by temporarily
|
||||
# making REQUESTS_CA_BUNDLE point to a nonexistent path.
|
||||
monkeypatch.setenv("REQUESTS_CA_BUNDLE", "/nonexistent/path.pem")
|
||||
ctx = find_ca_bundle()
|
||||
# SSL_CERT_FILE still valid → should return a path
|
||||
assert isinstance(ctx, str)
|
||||
assert os.path.isfile(ctx)
|
||||
|
||||
def test_ssl_cert_file_beats_node_extra_ca_certs(self, monkeypatch, tmp_path):
|
||||
"""SSL_CERT_FILE takes precedence over NODE_EXTRA_CA_CERTS."""
|
||||
_clean_env(monkeypatch)
|
||||
pem = tmp_path / "ca.pem"
|
||||
pem.write_bytes(_SELF_SIGNED_CA_PEM)
|
||||
|
||||
monkeypatch.setenv("SSL_CERT_FILE", str(pem))
|
||||
monkeypatch.setenv("NODE_EXTRA_CA_CERTS", "/nonexistent/node.pem")
|
||||
|
||||
ctx = find_ca_bundle()
|
||||
assert isinstance(ctx, str)
|
||||
assert os.path.isfile(ctx)
|
||||
|
||||
def test_requests_ca_bundle_beats_node_extra_ca_certs(self, monkeypatch, tmp_path):
|
||||
"""REQUESTS_CA_BUNDLE is used before NODE_EXTRA_CA_CERTS."""
|
||||
_clean_env(monkeypatch)
|
||||
pem = tmp_path / "ca.pem"
|
||||
pem.write_bytes(_SELF_SIGNED_CA_PEM)
|
||||
|
||||
monkeypatch.setenv("SSL_CERT_FILE", "/nonexistent/ssl.pem")
|
||||
monkeypatch.setenv("REQUESTS_CA_BUNDLE", str(pem))
|
||||
monkeypatch.setenv("NODE_EXTRA_CA_CERTS", "/nonexistent/node.pem")
|
||||
|
||||
ctx = find_ca_bundle()
|
||||
assert isinstance(ctx, str)
|
||||
assert os.path.isfile(ctx)
|
||||
|
||||
|
||||
class TestFindCaBundleNonexistentPaths:
|
||||
def test_nonexistent_path_is_skipped(self, monkeypatch):
|
||||
_clean_env(monkeypatch)
|
||||
monkeypatch.setenv("SSL_CERT_FILE", "/nonexistent/path/ca.pem")
|
||||
assert find_ca_bundle() is None
|
||||
|
||||
def test_all_nonexistent_returns_none(self, monkeypatch):
|
||||
_clean_env(monkeypatch)
|
||||
monkeypatch.setenv("SSL_CERT_FILE", "/no/such/file1.pem")
|
||||
monkeypatch.setenv("REQUESTS_CA_BUNDLE", "/no/such/file2.pem")
|
||||
monkeypatch.setenv("NODE_EXTRA_CA_CERTS", "/no/such/file3.pem")
|
||||
assert find_ca_bundle() is None
|
||||
|
||||
def test_first_nonexistent_falls_through_to_valid(self, monkeypatch, ca_pem_file):
|
||||
"""When the first env var path is missing, the next valid one is used."""
|
||||
_clean_env(monkeypatch)
|
||||
monkeypatch.setenv("SSL_CERT_FILE", "/nonexistent/ssl.pem")
|
||||
monkeypatch.setenv("REQUESTS_CA_BUNDLE", ca_pem_file)
|
||||
|
||||
ctx = find_ca_bundle()
|
||||
assert ctx == ca_pem_file
|
||||
Loading…
Add table
Add a link
Reference in a new issue