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.
This commit is contained in:
teknium1 2026-07-04 02:49:31 -07:00
parent 6ff7e78649
commit 86fcb2fe5f
No known key found for this signature in database
10 changed files with 922 additions and 250 deletions

View file

@ -471,16 +471,16 @@ def test_merge_mappings_rotate_mints_fresh_tokens():
# ---------------------------------------------------------------------------
# Uncovered provider detection (regression: non-bearer providers bypass)
# Uncovered provider detection (regression: signature-auth providers bypass)
# ---------------------------------------------------------------------------
def test_uncovered_providers_detects_anthropic_aws(hermes_home, monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
def test_uncovered_providers_detects_aws_gcp_appdefault(hermes_home, monkeypatch):
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAEXAMPLE")
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/etc/gcp.json")
uncovered = ip.discover_uncovered_providers()
assert "ANTHROPIC_API_KEY" in uncovered
assert "AWS_ACCESS_KEY_ID" in uncovered
assert "GOOGLE_APPLICATION_CREDENTIALS" in uncovered
def test_uncovered_providers_explicit_names_empty():
@ -496,6 +496,22 @@ def test_uncovered_providers_skips_bearer_providers(hermes_home, monkeypatch):
assert "OPENROUTER_API_KEY" not in uncovered
def test_uncovered_providers_skips_header_auth_providers(hermes_home, monkeypatch):
"""Anthropic / Azure / Gemini moved from 'uncovered' to first-class
header-auth swapped providers they must no longer be reported as
uncovered."""
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "az-test")
monkeypatch.setenv("GEMINI_API_KEY", "g-test")
monkeypatch.setenv("GOOGLE_API_KEY", "g-test-alias")
uncovered = ip.discover_uncovered_providers()
assert "ANTHROPIC_API_KEY" not in uncovered
assert "AZURE_OPENAI_API_KEY" not in uncovered
assert "GEMINI_API_KEY" not in uncovered
assert "GOOGLE_API_KEY" not in uncovered
# ---------------------------------------------------------------------------
# Binary discovery + lazy install
# ---------------------------------------------------------------------------
@ -1357,40 +1373,276 @@ def test_default_deny_includes_ipv4_mapped_v6(tmp_path):
# ---------------------------------------------------------------------------
# v3: split LLM-specific blocked tier (P1 #3 + P2 non_bearer tiers)
# Header-auth providers (x-api-key family) — match_headers + aliases
# ---------------------------------------------------------------------------
def test_blocked_providers_subset_of_uncovered(hermes_home, monkeypatch):
"""The strict tier that BLOCKS start must be a subset of the
uncovered-but-warn tier; the wizard surfaces ALL uncovered but only
blocks the LLM-specific ones."""
def test_header_auth_providers_discovered(hermes_home, monkeypatch):
"""Anthropic / Azure / Gemini mint mappings with their native auth
headers instead of landing in the uncovered list."""
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "az-test")
monkeypatch.setenv("GEMINI_API_KEY", "g-test")
ms = {m.real_env_name: m for m in ip.discover_provider_mappings()}
assert "ANTHROPIC_API_KEY" in ms
assert "AZURE_OPENAI_API_KEY" in ms
assert "GEMINI_API_KEY" in ms
anth = ms["ANTHROPIC_API_KEY"]
assert "x-api-key" in anth.match_headers
assert "api.anthropic.com" in anth.upstream_hosts
azure = ms["AZURE_OPENAI_API_KEY"]
assert "api-key" in azure.match_headers
gem = ms["GEMINI_API_KEY"]
assert "x-goog-api-key" in gem.match_headers
assert "GOOGLE_API_KEY" in gem.alias_env_names
def test_gemini_alias_collapses_to_single_mapping(hermes_home, monkeypatch):
"""GEMINI_API_KEY + GOOGLE_API_KEY are the same upstream credential —
two require-rules on the same host would reject each other's requests,
so exactly ONE mapping must be minted regardless of which names are
set."""
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIA-test")
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/etc/gcp.json")
monkeypatch.setenv("GEMINI_API_KEY", "g-test")
monkeypatch.setenv("GOOGLE_API_KEY", "g-test-alias")
ms = [m for m in ip.discover_provider_mappings()
if "generativelanguage" in " ".join(m.upstream_hosts)]
assert len(ms) == 1
assert ms[0].real_env_name == "GEMINI_API_KEY"
uncovered = set(ip.discover_uncovered_providers())
blocked = set(ip.discover_blocked_providers())
# Strict subset: every blocked is also uncovered, but the reverse
# doesn't hold.
assert blocked.issubset(uncovered)
# AWS / GCP appdefault present but NOT blocked (those are present on
# most dev laptops for unrelated cloud tooling).
assert "AWS_ACCESS_KEY_ID" in uncovered
assert "AWS_ACCESS_KEY_ID" not in blocked
assert "GOOGLE_APPLICATION_CREDENTIALS" in uncovered
assert "GOOGLE_APPLICATION_CREDENTIALS" not in blocked
# LLM-specific providers ARE blocked.
assert "ANTHROPIC_API_KEY" in blocked
assert "GEMINI_API_KEY" in blocked
# GOOGLE_API_KEY is an alias for GEMINI_API_KEY (same generativelanguage
# LLM endpoint) — it must be in the fail-closed tier, not warn-only,
# or fail_on_uncovered_providers gives false coverage.
assert "GOOGLE_API_KEY" in blocked
def test_gemini_alias_only_still_mints_mapping(hermes_home, monkeypatch):
"""An operator with ONLY GOOGLE_API_KEY set still gets Gemini coverage
(mapping keyed on the canonical name)."""
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
monkeypatch.setenv("GOOGLE_API_KEY", "g-test-alias")
ms = {m.real_env_name: m for m in ip.discover_provider_mappings()}
assert "GEMINI_API_KEY" in ms
assert "GOOGLE_API_KEY" in ms["GEMINI_API_KEY"].alias_env_names
def test_build_proxy_config_emits_per_provider_match_headers(tmp_path):
"""Header-auth mappings carry their native headers into the secrets
rule; bearer mappings keep the Authorization default."""
bearer = _sample_mapping()
anth = ip.TokenMapping(
proxy_token=ip.mint_proxy_token("anthropic"),
real_env_name="ANTHROPIC_API_KEY",
upstream_hosts=("api.anthropic.com",),
match_headers=("x-api-key", "Authorization"),
)
cfg = ip.build_proxy_config(
mappings=[bearer, anth],
ca_cert=tmp_path / "ca.crt",
ca_key=tmp_path / "ca.key",
)
rules = {r["source"]["var"]: r for r in cfg["transforms"][1]["config"]["secrets"]}
assert rules["OPENROUTER_API_KEY"]["replace"]["match_headers"] == ["Authorization"]
assert rules["ANTHROPIC_API_KEY"]["replace"]["match_headers"] == [
"x-api-key", "Authorization",
]
# Fail-closed still applies to header-auth providers.
assert rules["ANTHROPIC_API_KEY"]["replace"]["require"] is True
# Header-auth hosts land on the allowlist too.
assert "api.anthropic.com" in cfg["transforms"][0]["config"]["domains"]
def test_mappings_roundtrip_preserves_headers_and_aliases(hermes_home):
m = ip.TokenMapping(
proxy_token=ip.mint_proxy_token("gemini"),
real_env_name="GEMINI_API_KEY",
upstream_hosts=("generativelanguage.googleapis.com",),
match_headers=("x-goog-api-key",),
alias_env_names=("GOOGLE_API_KEY",),
)
ip.write_mappings([m])
loaded = ip.load_mappings()
assert loaded[0].match_headers == ("x-goog-api-key",)
assert loaded[0].alias_env_names == ("GOOGLE_API_KEY",)
def test_load_mappings_legacy_entries_default_to_bearer(hermes_home):
"""mappings.json written before the match_headers/alias fields must
load with the Authorization default (same behavior as at write time)."""
import json as _json
state = ip._proxy_state_dir()
(state / "mappings.json").write_text(_json.dumps({
"version": 1,
"tokens": [{
"proxy_token": "openrouter-legacy-token",
"env_name": "OPENROUTER_API_KEY",
"upstream_hosts": ["openrouter.ai"],
}],
}), encoding="utf-8")
loaded = ip.load_mappings()
assert loaded[0].match_headers == ("Authorization",)
assert loaded[0].alias_env_names == ()
def test_subprocess_env_mirrors_alias_into_canonical(hermes_home, monkeypatch):
"""When only the alias (GOOGLE_API_KEY) is exported, the proxy child
env must still carry the canonical name the secrets rule reads."""
m = ip.TokenMapping(
proxy_token=ip.mint_proxy_token("gemini"),
real_env_name="GEMINI_API_KEY",
upstream_hosts=("generativelanguage.googleapis.com",),
match_headers=("x-goog-api-key",),
alias_env_names=("GOOGLE_API_KEY",),
)
ip.write_mappings([m])
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
monkeypatch.setenv("GOOGLE_API_KEY", "g-real-secret")
env = ip._build_proxy_subprocess_env()
assert env.get("GEMINI_API_KEY") == "g-real-secret"
def test_subprocess_env_canonical_wins_over_alias(hermes_home, monkeypatch):
m = ip.TokenMapping(
proxy_token=ip.mint_proxy_token("gemini"),
real_env_name="GEMINI_API_KEY",
upstream_hosts=("generativelanguage.googleapis.com",),
match_headers=("x-goog-api-key",),
alias_env_names=("GOOGLE_API_KEY",),
)
ip.write_mappings([m])
monkeypatch.setenv("GEMINI_API_KEY", "g-canonical")
monkeypatch.setenv("GOOGLE_API_KEY", "g-alias")
env = ip._build_proxy_subprocess_env()
assert env.get("GEMINI_API_KEY") == "g-canonical"
# ---------------------------------------------------------------------------
# Management API (hot reload)
# ---------------------------------------------------------------------------
def test_build_proxy_config_enables_management_listener(tmp_path):
cfg = ip.build_proxy_config(
mappings=[_sample_mapping()],
ca_cert=tmp_path / "ca.crt",
ca_key=tmp_path / "ca.key",
tunnel_port=9090,
)
mgmt = cfg["management"]
# Loopback only — sandboxes must never reach the management surface.
assert mgmt["listen"].startswith("127.0.0.1:")
assert mgmt["listen"].endswith(":9092") # tunnel_port + 2
assert mgmt["api_key_env"] == ip._MGMT_API_KEY_ENV
def test_ensure_management_token_persists_and_is_stable(hermes_home):
t1 = ip.ensure_management_token()
t2 = ip.ensure_management_token()
assert t1 == t2
assert t1.startswith("hermes-mgmt-")
p = ip._proxy_state_dir() / "management.token"
assert p.exists()
assert (p.stat().st_mode & 0o777) == 0o600
def test_ensure_management_token_force_rotates(hermes_home):
t1 = ip.ensure_management_token()
t2 = ip.ensure_management_token(force=True)
assert t1 != t2
def test_reload_proxy_refuses_when_not_running(hermes_home, monkeypatch):
monkeypatch.setattr(ip, "_read_pid", lambda: None)
with pytest.raises(RuntimeError, match="not running"):
ip.reload_proxy()
def test_reload_proxy_refuses_on_pre_management_config(hermes_home, monkeypatch):
"""A config written before management support has no listener — the
error must tell the operator to re-setup + restart once."""
monkeypatch.setattr(ip, "_read_pid", lambda: 4242)
monkeypatch.setattr(ip, "_pid_alive", lambda pid: True)
# No proxy.yaml at all -> _read_management_listen_from_config is None.
with pytest.raises(RuntimeError, match="restart"):
ip.reload_proxy()
def test_reload_proxy_posts_bearer_to_management_endpoint(hermes_home, monkeypatch):
monkeypatch.setattr(ip, "_read_pid", lambda: 4242)
monkeypatch.setattr(ip, "_pid_alive", lambda pid: True)
monkeypatch.setattr(
ip, "_read_management_listen_from_config",
lambda config_path=None: ("127.0.0.1", 9092),
)
ip.ensure_management_token()
captured = {}
class _FakeResp:
status = 200
def __enter__(self):
return self
def __exit__(self, *a):
return False
def fake_urlopen(req, timeout=None):
captured["url"] = req.full_url
captured["method"] = req.get_method()
captured["auth"] = req.get_header("Authorization")
return _FakeResp()
import urllib.request
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
assert ip.reload_proxy() is True
assert captured["url"] == "http://127.0.0.1:9092/v1/reload"
assert captured["method"] == "POST"
token = (ip._proxy_state_dir() / "management.token").read_text().strip()
assert captured["auth"] == f"Bearer {token}"
def test_start_proxy_injects_management_key_env(hermes_home, monkeypatch):
"""When the generated config has a management listener, start_proxy
must inject the bearer key env var v0.39 refuses to start when
api_key_env is empty."""
cfg_path = ip._proxy_state_dir() / "proxy.yaml"
cfg = ip.build_proxy_config(
mappings=[_sample_mapping()],
ca_cert=hermes_home / "ca.crt",
ca_key=hermes_home / "ca.key",
http_listen=["127.0.0.1:9090"],
)
ip.write_proxy_config(cfg)
(hermes_home / "bin").mkdir(parents=True, exist_ok=True)
fake_bin = hermes_home / "bin" / "iron-proxy"
fake_bin.write_text("#!/bin/sh\nsleep 60\n")
fake_bin.chmod(0o755)
captured_env = {}
class _FakeProc:
pid = 99999
def poll(self):
return None
def fake_popen(cmd, **kw):
captured_env.update(kw.get("env") or {})
return _FakeProc()
monkeypatch.setattr(ip.subprocess, "Popen", fake_popen)
monkeypatch.setattr(ip, "_write_pidfile_safely", lambda pf, pid: None)
monkeypatch.setattr(ip, "_port_listening", lambda h, p: True)
monkeypatch.setattr(ip, "get_status", lambda: ip.ProxyStatus(pid=99999, listening=True))
ip.start_proxy(binary=fake_bin, config_path=cfg_path, install_if_missing=False)
assert captured_env.get(ip._MGMT_API_KEY_ENV)
assert captured_env[ip._MGMT_API_KEY_ENV].startswith("hermes-mgmt-")
# ---------------------------------------------------------------------------

View file

@ -198,7 +198,6 @@ def test_cmd_setup_rejects_invalid_tunnel_port_range(hermes_home, monkeypatch, b
# ---------------------------------------------------------------------------
# cmd_start — fail_on_uncovered_providers + Bitwarden rotation wire-up
# ---------------------------------------------------------------------------
@ -212,21 +211,6 @@ def test_cmd_start_refuses_when_proxy_disabled(hermes_home, monkeypatch):
assert rc == 1
def test_cmd_start_refuses_on_uncovered_provider_when_strict(hermes_home, monkeypatch):
"""fail_on_uncovered_providers=true + ANTHROPIC_API_KEY in env =
refuse to start (real credential would otherwise leak into sandbox)."""
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("proxy", {})["enabled"] = True
cfg["proxy"]["fail_on_uncovered_providers"] = True
save_config(cfg)
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
rc = proxy_cli.cmd_start(_args())
assert rc == 1
def test_cmd_start_honors_auto_install_false(hermes_home, monkeypatch):
from hermes_cli.config import load_config, save_config
@ -244,7 +228,6 @@ def test_cmd_start_honors_auto_install_false(hermes_home, monkeypatch):
monkeypatch.setattr(ip, "start_proxy", fake_start_proxy)
monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: [])
monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: [])
rc = proxy_cli.cmd_start(_args())
assert rc == 0
@ -262,7 +245,6 @@ def test_cmd_start_passes_bitwarden_refresh_flag_when_credential_source_is_bitwa
cfg = load_config()
cfg.setdefault("proxy", {})["enabled"] = True
cfg["proxy"]["credential_source"] = "bitwarden"
cfg["proxy"]["fail_on_uncovered_providers"] = False
cfg.setdefault("secrets", {})["bitwarden"] = {
"enabled": True,
"project_id": "test-proj-id",
@ -284,7 +266,6 @@ def test_cmd_start_passes_bitwarden_refresh_flag_when_credential_source_is_bitwa
return s
monkeypatch.setattr(ip, "start_proxy", fake_start_proxy)
monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: [])
monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: [])
rc = proxy_cli.cmd_start(_args())
assert rc == 0
@ -301,7 +282,6 @@ def test_cmd_start_refuses_when_bitwarden_token_missing(hermes_home, monkeypatch
cfg = load_config()
cfg.setdefault("proxy", {})["enabled"] = True
cfg["proxy"]["credential_source"] = "bitwarden"
cfg["proxy"]["fail_on_uncovered_providers"] = False
cfg.setdefault("secrets", {})["bitwarden"] = {
"enabled": True,
"project_id": "test-proj-id",
@ -315,7 +295,6 @@ def test_cmd_start_refuses_when_bitwarden_token_missing(hermes_home, monkeypatch
pytest.fail("start_proxy should not be invoked when BWS token missing")
monkeypatch.setattr(ip, "start_proxy", must_not_call)
monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: [])
monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: [])
rc = proxy_cli.cmd_start(_args())
assert rc == 1
@ -328,7 +307,6 @@ def test_cmd_start_does_not_pass_bitwarden_refresh_when_credential_source_is_env
cfg = load_config()
cfg.setdefault("proxy", {})["enabled"] = True
cfg["proxy"]["credential_source"] = "env"
cfg["proxy"]["fail_on_uncovered_providers"] = False
save_config(cfg)
captured: dict = {}
@ -553,7 +531,6 @@ def test_cmd_start_refuses_when_bitwarden_mode_but_disabled(hermes_home, monkeyp
cfg = load_config()
cfg.setdefault("proxy", {})["enabled"] = True
cfg["proxy"]["credential_source"] = "bitwarden"
cfg["proxy"]["fail_on_uncovered_providers"] = False
cfg.setdefault("secrets", {})["bitwarden"] = {"enabled": False}
save_config(cfg)
@ -561,7 +538,6 @@ def test_cmd_start_refuses_when_bitwarden_mode_but_disabled(hermes_home, monkeyp
pytest.fail("start_proxy must not run when bitwarden mode is broken")
monkeypatch.setattr(ip, "start_proxy", must_not_call)
monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: [])
monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: [])
rc = proxy_cli.cmd_start(_args())
assert rc == 1
@ -578,7 +554,6 @@ def test_cmd_start_bitwarden_disabled_proceeds_with_env_fallback(
cfg.setdefault("proxy", {})["enabled"] = True
cfg["proxy"]["credential_source"] = "bitwarden"
cfg["proxy"]["allow_env_fallback"] = True
cfg["proxy"]["fail_on_uncovered_providers"] = False
cfg.setdefault("secrets", {})["bitwarden"] = {"enabled": False}
save_config(cfg)
@ -593,7 +568,6 @@ def test_cmd_start_bitwarden_disabled_proceeds_with_env_fallback(
monkeypatch.setattr(ip, "start_proxy", fake_start_proxy)
monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: [])
monkeypatch.setattr(ip, "discover_blocked_providers", lambda **kw: [])
rc = proxy_cli.cmd_start(_args())
assert rc == 0

View file

@ -166,3 +166,203 @@ def test_iron_proxy_swaps_authorization_header_end_to_end(hermes_home, monkeypat
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()