hermes-agent/tests/test_iron_proxy_e2e.py
teknium1 86fcb2fe5f
feat(egress): first-class x-api-key providers + hot reload via management API
Both wired against features the iron-proxy author (@mslipper) confirmed on
PR #30179 — and both verified present in the pinned v0.39.0 source.

Header-auth providers (match_headers):
- New _HEADER_AUTH_PROVIDERS: Anthropic native (x-api-key), Azure OpenAI
  (api-key on *.openai.azure.com / *.cognitiveservices / *.services.ai),
  Gemini (x-goog-api-key + ?key= query param via match_query).
- TokenMapping grows match_headers + alias_env_names; per-provider header
  sets flow into the secrets rules; mappings.json roundtrips them
  (legacy files load with the Authorization default).
- GEMINI_API_KEY / GOOGLE_API_KEY collapse into ONE mapping (two
  require-rules on the same host would reject each other); the sandbox
  gets the token under both names, and the proxy child env mirrors the
  alias into the canonical name when only the alias is set.
- Docker backend injects alias env names alongside canonical ones.
- The fail-closed tier is now empty, so fail_on_uncovered_providers and
  discover_blocked_providers are deleted (dead toggle otherwise);
  _NON_BEARER_PROVIDERS shrinks to genuinely-unswappable signature auth
  (AWS SigV4, GCP service-account OAuth) — warn-only, as before.

Management API (hot reload):
- Generated proxy.yaml enables the v0.39 management listener: loopback
  only at tunnel_port+2, bearer key from HERMES_IRON_PROXY_MGMT_KEY.
- Key minted at setup (management.token, 0600); start_proxy injects it
  (v0.39 refuses to start when api_key_env is empty).
- hermes egress reload -> POST /v1/reload: re-reads proxy.yaml and
  atomically swaps the pipeline; 422 leaves the running ruleset
  untouched; actionable errors for not-running / pre-management config /
  key mismatch. Secrets changes still require restart (daemon env is
  read at spawn) — the CLI says so.

Validation: 218/218 unit+CLI+docker tests; 3/3 gated live E2E against the
real v0.39.0 binary (Authorization swap, x-api-key swap, live reload with
token rotation on the same pid). Docs updated.
2026-07-04 02:49:31 -07:00

368 lines
13 KiB
Python

"""End-to-end smoke test for the iron-proxy egress integration.
Spins up the REAL iron-proxy binary (auto-installed if not present), routes
a curl request through it against a local fake upstream, and verifies that
the Authorization header was swapped from a proxy token to a real secret.
Gated on the network. Skipped by default in CI unless the user explicitly
opts in with --run-e2e or HERMES_RUN_E2E=1. This is intentional — the test
downloads ~16MB and requires both `openssl` and `curl` to be present.
"""
from __future__ import annotations
import os
import socket
import subprocess
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Optional
import pytest
from agent.proxy_sources import iron_proxy as ip
pytestmark = pytest.mark.skipif(
os.environ.get("HERMES_RUN_E2E", "0") != "1",
reason="E2E proxy test — set HERMES_RUN_E2E=1 to run (requires network + curl + openssl)",
)
@pytest.fixture
def hermes_home(tmp_path, monkeypatch):
home = tmp_path / "hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
return home
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class _CaptureHandler(BaseHTTPRequestHandler):
"""Records the Authorization header of every incoming request."""
captured_auth: Optional[str] = None # class-level so tests can read it
def do_GET(self):
type(self).captured_auth = self.headers.get("Authorization")
body = b'{"ok": true}'
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args, **kwargs):
return # silence access log
def test_iron_proxy_swaps_authorization_header_end_to_end(hermes_home, monkeypatch):
"""Real binary, real CA, real curl. Verify the proxy swaps a proxy-token
Authorization header for the real bearer value before forwarding."""
if not __import__("shutil").which("curl"):
pytest.skip("curl not available")
if not __import__("shutil").which("openssl"):
pytest.skip("openssl not available")
# ----- fake upstream ----------------------------------------------------
upstream_port = _free_port()
server = HTTPServer(("127.0.0.1", upstream_port), _CaptureHandler)
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
server_thread.start()
try:
# ----- iron-proxy install + CA + config ---------------------------
binary = ip.install_iron_proxy()
assert binary.exists()
ca_crt, ca_key = ip.ensure_ca_cert()
assert ca_crt.exists()
real_secret = "sk-real-upstream-value-deadbeef"
monkeypatch.setenv("TEST_UPSTREAM_KEY", real_secret)
proxy_token = ip.mint_proxy_token("test")
mapping = ip.TokenMapping(
proxy_token=proxy_token,
real_env_name="TEST_UPSTREAM_KEY",
upstream_hosts=("127.0.0.1",),
)
tunnel_port = _free_port()
cfg = ip.build_proxy_config(
mappings=[mapping],
ca_cert=ca_crt,
ca_key=ca_key,
tunnel_port=tunnel_port,
allowed_hosts=["127.0.0.1"],
# Test target is on loopback — clear the default IMDS+loopback
# deny list so iron-proxy will dial 127.0.0.1.
upstream_deny_cidrs=[],
# Hermetic: pin the bind to loopback. Without this, Linux
# hosts with docker0 present would bind the bridge gateway
# (the production default) and the loopback curl below would
# never reach the proxy.
http_listen=[f"127.0.0.1:{tunnel_port}"],
)
ip.write_proxy_config(cfg)
ip.write_mappings([mapping])
# ----- start the proxy --------------------------------------------
try:
status = ip.start_proxy()
except RuntimeError as exc:
pytest.skip(f"iron-proxy could not start in this environment: {exc}")
assert status.pid is not None
# Wait up to 10s for the listener to come up.
for _ in range(50):
if ip._port_listening("127.0.0.1", tunnel_port):
break
time.sleep(0.2)
else:
pytest.fail("iron-proxy never started listening on the tunnel port")
# ----- request through the proxy ----------------------------------
# The fake upstream listens on plain HTTP (not HTTPS). Plain-HTTP
# absolute-form forwards are served by the http_listen listener on
# tunnel_port + 1 (tunnel_port itself is the CONNECT/MITM listener
# that HTTPS_PROXY traffic hits). The secrets transform fires on
# the plain forward too, swapping the Authorization header.
result = subprocess.run(
[
"curl",
"--silent",
"--max-time", "10",
"-x", f"http://127.0.0.1:{tunnel_port + 1}",
"-H", f"Authorization: Bearer {proxy_token}",
f"http://127.0.0.1:{upstream_port}/",
],
capture_output=True,
text=True,
)
assert result.returncode == 0, f"curl failed: {result.stderr}"
# Some iron-proxy versions return 200 with no body; only the swap matters.
captured = _CaptureHandler.captured_auth
assert captured is not None, "upstream never received the request"
assert real_secret in captured, (
f"Authorization header was not swapped — upstream saw: {captured!r}"
)
assert proxy_token not in captured, (
f"Proxy token leaked through to upstream: {captured!r}"
)
finally:
# ----- cleanup ------------------------------------------------------
try:
ip.stop_proxy()
except Exception:
pass
server.shutdown()
server.server_close()
class _CaptureXApiKeyHandler(BaseHTTPRequestHandler):
"""Records the x-api-key header of every incoming request."""
captured_key: Optional[str] = None
def do_GET(self):
type(self).captured_key = self.headers.get("x-api-key")
body = b'{"ok": true}'
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args, **kwargs):
return
def test_iron_proxy_swaps_x_api_key_header_end_to_end(hermes_home, monkeypatch):
"""Header-auth providers: the secrets transform must swap the proxy
token out of a NON-Authorization header (x-api-key — the Anthropic
native scheme) on the pinned binary."""
if not __import__("shutil").which("curl"):
pytest.skip("curl not available")
if not __import__("shutil").which("openssl"):
pytest.skip("openssl not available")
upstream_port = _free_port()
server = HTTPServer(("127.0.0.1", upstream_port), _CaptureXApiKeyHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
try:
binary = ip.install_iron_proxy()
assert binary.exists()
ca_crt, ca_key = ip.ensure_ca_cert()
real_secret = "sk-ant-real-value-cafebabe"
monkeypatch.setenv("TEST_XAPI_KEY", real_secret)
proxy_token = ip.mint_proxy_token("anthropic")
mapping = ip.TokenMapping(
proxy_token=proxy_token,
real_env_name="TEST_XAPI_KEY",
upstream_hosts=("127.0.0.1",),
match_headers=("x-api-key", "Authorization"),
)
tunnel_port = _free_port()
cfg = ip.build_proxy_config(
mappings=[mapping],
ca_cert=ca_crt,
ca_key=ca_key,
tunnel_port=tunnel_port,
allowed_hosts=["127.0.0.1"],
upstream_deny_cidrs=[],
http_listen=[f"127.0.0.1:{tunnel_port}"],
)
ip.write_proxy_config(cfg)
ip.write_mappings([mapping])
try:
status = ip.start_proxy()
except RuntimeError as exc:
pytest.skip(f"iron-proxy could not start in this environment: {exc}")
assert status.pid is not None
for _ in range(50):
if ip._port_listening("127.0.0.1", tunnel_port):
break
time.sleep(0.2)
else:
pytest.fail("iron-proxy never started listening on the tunnel port")
result = subprocess.run(
[
"curl",
"--silent",
"--max-time", "10",
"-x", f"http://127.0.0.1:{tunnel_port + 1}",
"-H", f"x-api-key: {proxy_token}",
f"http://127.0.0.1:{upstream_port}/",
],
capture_output=True,
text=True,
)
assert result.returncode == 0, f"curl failed: {result.stderr}"
captured = _CaptureXApiKeyHandler.captured_key
assert captured is not None, "upstream never received the request"
assert real_secret in captured, (
f"x-api-key header was not swapped — upstream saw: {captured!r}"
)
assert proxy_token not in captured, (
f"Proxy token leaked through to upstream: {captured!r}"
)
finally:
try:
ip.stop_proxy()
except Exception:
pass
server.shutdown()
server.server_close()
def test_iron_proxy_management_reload_end_to_end(hermes_home, monkeypatch):
"""Real binary: the management listener comes up, an authenticated
POST /v1/reload succeeds after a config edit, and the edited ruleset
takes effect WITHOUT a restart (same pid)."""
if not __import__("shutil").which("curl"):
pytest.skip("curl not available")
if not __import__("shutil").which("openssl"):
pytest.skip("openssl not available")
upstream_port = _free_port()
server = HTTPServer(("127.0.0.1", upstream_port), _CaptureHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
try:
binary = ip.install_iron_proxy()
assert binary.exists()
ca_crt, ca_key = ip.ensure_ca_cert()
real_secret = "sk-real-reload-value-0badf00d"
monkeypatch.setenv("TEST_RELOAD_KEY", real_secret)
token_v1 = ip.mint_proxy_token("v1")
def _write_cfg(mapping):
cfg = ip.build_proxy_config(
mappings=[mapping],
ca_cert=ca_crt,
ca_key=ca_key,
tunnel_port=tunnel_port,
allowed_hosts=["127.0.0.1"],
upstream_deny_cidrs=[],
http_listen=[f"127.0.0.1:{tunnel_port}"],
)
ip.write_proxy_config(cfg)
ip.write_mappings([mapping])
tunnel_port = _free_port()
_write_cfg(ip.TokenMapping(
proxy_token=token_v1,
real_env_name="TEST_RELOAD_KEY",
upstream_hosts=("127.0.0.1",),
))
try:
status = ip.start_proxy()
except RuntimeError as exc:
pytest.skip(f"iron-proxy could not start in this environment: {exc}")
pid_before = status.pid
assert pid_before is not None
for _ in range(50):
if ip._port_listening("127.0.0.1", tunnel_port):
break
time.sleep(0.2)
else:
pytest.fail("iron-proxy never started listening")
# Rotate the sandbox-visible token in the config, then hot-reload.
token_v2 = ip.mint_proxy_token("v2")
_write_cfg(ip.TokenMapping(
proxy_token=token_v2,
real_env_name="TEST_RELOAD_KEY",
upstream_hosts=("127.0.0.1",),
))
assert ip.reload_proxy() is True
# Same daemon (no restart) ...
assert ip.get_status().pid == pid_before
# ... but the NEW token now swaps.
_CaptureHandler.captured_auth = None
result = subprocess.run(
[
"curl", "--silent", "--max-time", "10",
"-x", f"http://127.0.0.1:{tunnel_port + 1}",
"-H", f"Authorization: Bearer {token_v2}",
f"http://127.0.0.1:{upstream_port}/",
],
capture_output=True, text=True,
)
assert result.returncode == 0, f"curl failed: {result.stderr}"
captured = _CaptureHandler.captured_auth
assert captured is not None and real_secret in captured, (
f"post-reload token was not swapped — upstream saw: {captured!r}"
)
finally:
try:
ip.stop_proxy()
except Exception:
pass
server.shutdown()
server.server_close()