fix(egress): v4 round — bridge bind on Linux, listener-role split, fallback gate, audit.log truth

Addresses GodsBoy's May 30 follow-up review on PR #30179.

P0 — Linux sandboxes could not reach the proxy:
- _default_http_listen now binds the docker bridge gateway on Linux
  (host.docker.internal resolves to the bridge gateway there; the old
  loopback-only bind was unreachable from containers). Loopback stays
  the default on Docker Desktop platforms; bridge-less Linux falls back
  to loopback with a warning.
- Live-testing the fix against the real v0.39 binary surfaced a second
  latent bug the host-side E2E had masked: v0.39's http_listen does NOT
  terminate CONNECT — tunnel_listen does. HTTPS_PROXY traffic through
  http_listen got 400s from upstream. build_proxy_config now binds
  tunnel_listen (CONNECT/MITM) on tunnel_port and http_listen
  (plain-HTTP forwards) on tunnel_port+1; docker.py points HTTPS_PROXY
  at tunnel_port and HTTP_PROXY at tunnel_port+1.
- Liveness probes (start_proxy poll loop, get_status) now probe the
  CONFIGURED bind host via _read_http_listen_from_config() instead of
  hardcoded loopback, which would have killed a healthy bridge-bound
  daemon as 'never came up'.

P2 — allow_env_fallback dead on the partial-secret path:
- The missing-secret branch in _build_proxy_subprocess_env now honors
  proxy.allow_env_fallback exactly as its own error message promises.
- cmd_start refuses (or warns, with the fallback flag) when
  credential_source=bitwarden but secrets.bitwarden is disabled/missing
  — closing the silent-degrade-to-host-env hole.

P2 — audit.log decoy on v0.39:
- Wizard pre-create failure downgraded to a warning (file is
  non-load-bearing until the version bump); success line qualified as
  'reserved'; user + dev docs stop telling operators to wire monitoring
  to the path today.

P3 — metrics comment no longer claims 'hermes egress status' surfaces
the ephemeral metrics port (it can't; :0 is random and unrecorded).

Validation: 180 unit tests pass (9 new), gated E2E passes, and a live
end-to-end run against the real binary verified CONNECT-MITM with
Authorization swap on tunnel_listen, plain-HTTP swap on tunnel_port+1,
403 on non-allowlisted hosts, and bind-host-aware status probing.
Docs gain a Linux firewall troubleshooting section (container→docker0
INPUT drops, e.g. ufw default-deny).
This commit is contained in:
Teknium 2026-06-10 05:12:37 -07:00
parent 42139c3cf0
commit e2c2e41137
No known key found for this signature in database
8 changed files with 500 additions and 128 deletions

View file

@ -37,8 +37,10 @@ Design summary
at proxy startup instead.
* The proxy runs as a managed subprocess (``hermes egress start``), pidfile
at ``<hermes_home>/proxy/iron-proxy.pid``, structured audit log at
``<hermes_home>/proxy/audit.log``.
at ``<hermes_home>/proxy/iron-proxy.pid``. Daemon output (including
per-request records on v0.39) goes to ``<hermes_home>/proxy/iron-proxy.log``;
``audit.log`` is pre-created but reserved for a future pin that supports
``log.audit_path``.
* Failures (binary missing, port collision, bad config) emit a one-line
warning and do *not* block agent startup. The Docker backend refuses to
@ -686,26 +688,44 @@ def mint_proxy_token(prefix: str = "hermes-proxy") -> str:
def _default_http_listen(tunnel_port: int) -> List[str]:
"""Build the list of host:port pairs the proxy should bind on.
"""Build the single host:port bind the proxy should listen on.
Always binds loopback (``127.0.0.1``) so host-side test tooling can hit
the proxy directly. On Linux we also bind the docker bridge gateway
(``172.17.0.1`` by default) so containers can reach the proxy via
``host.docker.internal:host-gateway``. We do NOT bind ``0.0.0.0`` —
that would expose the proxy (and, with a leaked sandbox token, the
user's API quota) to anyone on the local network.
iron-proxy v0.39 supports exactly ONE ``proxy.http_listen`` bind per
daemon process, so this returns a one-element list and the choice of
host matters:
On macOS / Windows Docker Desktop the bridge gateway is managed by
Desktop itself and ``host.docker.internal`` resolves via VPNkit, so
a single loopback bind is enough.
* **Linux:** bind the docker bridge gateway (``172.17.0.1`` by
default). Sandboxes reach the proxy via
``host.docker.internal:host-gateway``, which Docker resolves to
exactly this bridge gateway IP on Linux — a loopback-only bind is
unreachable from inside containers there. The bridge IP is still
host-local (it's an address on the host's ``docker0`` interface),
so host-side tooling and the status probe can reach it too. When
no docker bridge is detected (docker not installed / not started),
fall back to loopback — there are no sandboxes to serve in that
state, and the operator gets a warning.
* **macOS / Windows Docker Desktop:** ``host.docker.internal``
resolves via VPNkit to the host, so a loopback bind is reachable
from containers and is the least-exposed choice.
We never bind ``0.0.0.0`` — that would expose the proxy (and, with a
leaked sandbox token, the user's API quota) to anyone on the local
network. The bridge-gateway bind is reachable by other containers
on the default bridge network, which is unavoidable given v0.39's
single-bind limit; requests still require a minted proxy token and
an allowlisted upstream.
"""
binds = [f"127.0.0.1:{tunnel_port}"]
if platform.system() == "Linux":
bridge_ip = _detect_docker_bridge_ip()
if bridge_ip and bridge_ip != "127.0.0.1":
binds.append(f"{bridge_ip}:{tunnel_port}")
return binds
return [f"{bridge_ip}:{tunnel_port}"]
logger.warning(
"No docker bridge (docker0) detected — binding iron-proxy to "
"loopback only. Docker sandboxes will NOT be able to reach "
"the proxy until it is restarted with docker running."
)
return [f"127.0.0.1:{tunnel_port}"]
def _detect_docker_bridge_ip() -> Optional[str]:
@ -792,13 +812,14 @@ def build_proxy_config(
real secrets from its OWN environment via ``source: {type: env, var: ...}``;
the sandbox never sees them.
Bind policy: by default we bind loopback (``127.0.0.1``) plus the
docker bridge gateway IP on Linux (``172.17.0.1`` or whatever
``docker0`` resolves to). Sandboxes use ``host.docker.internal`` which
Linux Docker maps to the bridge gateway via ``--add-host``; macOS /
Windows Docker Desktop manage their own gateway. We do NOT bind
``0.0.0.0`` — a LAN peer with a leaked sandbox token could otherwise
spend the operator's API quota against any allowlisted upstream.
Bind policy: the sandbox-facing listeners (``tunnel_listen`` on
``tunnel_port``, plain-HTTP ``http_listen`` on ``tunnel_port + 1``)
bind the docker bridge gateway on Linux (``172.17.0.1`` or whatever
``docker0`` resolves to — that's what ``host.docker.internal``
resolves to inside containers there) and loopback on macOS / Windows
Docker Desktop. We do NOT bind ``0.0.0.0`` — a LAN peer with a
leaked sandbox token could otherwise spend the operator's API quota
against any allowlisted upstream.
SSRF policy: ``upstream_deny_cidrs`` defaults to a conservative deny
list covering loopback, link-local (incl. AWS/GCP/Azure IMDS at
@ -851,16 +872,34 @@ def build_proxy_config(
else:
deny_cidrs = list(upstream_deny_cidrs)
# Listen address. iron-proxy v0.39 takes a single string at
# ``proxy.http_listen`` — there is no plural ``http_listens`` field,
# despite earlier drafts of this module claiming v0.39 accepts both.
# An empirical strings(1) audit + a live "start the binary and
# observe the YAML unmarshal error" confirms the singular form is
# the only one the binary accepts. We bind loopback by default; the
# docker bridge listener is added as a second binary invocation if
# we ever need multi-bind, but a single bind is what v0.39 supports.
# Listen addresses. iron-proxy v0.39 takes a single string per
# listener field — there is no plural ``http_listens`` form, despite
# earlier drafts of this module claiming v0.39 accepts both. An
# empirical strings(1) audit + a live "start the binary and observe
# the YAML unmarshal error" confirms the singular form is the only
# one the binary accepts.
#
# LISTENER ROLES (verified live against the v0.39 binary):
# * ``tunnel_listen`` is the CONNECT + MITM listener. HTTPS through
# ``HTTPS_PROXY`` issues CONNECT — this is the listener sandboxes
# must reach. A CONNECT sent to ``http_listen`` is NOT terminated:
# v0.39 forwards it upstream as a regular request and the upstream
# responds 400.
# * ``http_listen`` is the absolute-form plain-HTTP forward listener
# (``HTTP_PROXY`` for ``http://`` URLs). Transforms fire here too.
# Both get the sandbox-facing bind host: tunnel on ``tunnel_port``,
# plain HTTP on ``tunnel_port + 1``.
#
# The bind host comes from _default_http_listen: the docker bridge
# gateway on Linux (containers reach the proxy via
# host.docker.internal, which maps to the bridge gateway there —
# loopback would be unreachable from inside sandboxes) and loopback
# on macOS/Windows Docker Desktop (where host.docker.internal routes
# to the host via VPNkit).
listens = list(http_listen) if http_listen else _default_http_listen(tunnel_port)
primary_listen = listens[0] if listens else f"127.0.0.1:{tunnel_port}"
bind_host = primary_listen.rsplit(":", 1)[0] or "127.0.0.1"
plain_http_listen = f"{bind_host}:{tunnel_port + 1}"
log_block: Dict = {"level": "info"}
# NOTE: ``log.audit_path`` is NOT a field in iron-proxy v0.39's
@ -888,19 +927,20 @@ def build_proxy_config(
"proxy_ip": "127.0.0.1",
},
"proxy": {
# http_listen is the HTTP-proxy listener that handles both plain
# HTTP forwards AND CONNECT tunnels for HTTPS. Sandboxes set
# `HTTPS_PROXY=http://host:tunnel_port` and the same listener
# serves both protocols. We bind loopback by default — NOT
# 0.0.0.0. LAN peers with a leaked sandbox token would
# otherwise be able to spend the operator's API quota against
# any allowlisted upstream.
"http_listen": primary_listen,
# The HTTPS-listener (direct TLS termination, no CONNECT) and
# the SOCKS5/CONNECT-only tunnel listener get loopback ephemeral
# ports — we don't expose them.
# tunnel_listen is the CONNECT/MITM listener — what sandboxes
# hit via `HTTPS_PROXY=http://host:tunnel_port` for HTTPS
# upstreams (curl/requests/node issue CONNECT through it).
# http_listen handles absolute-form plain-HTTP forwards
# (`HTTP_PROXY` for http:// URLs) on tunnel_port+1. Both
# bind the docker bridge gateway on Linux / loopback on
# Docker Desktop — NEVER 0.0.0.0. LAN peers with a leaked
# sandbox token would otherwise be able to spend the
# operator's API quota against any allowlisted upstream.
"tunnel_listen": primary_listen,
"http_listen": plain_http_listen,
# The HTTPS-listener (direct TLS termination, no CONNECT)
# gets a loopback ephemeral port — we don't expose it.
"https_listen": "127.0.0.1:0",
"tunnel_listen": "127.0.0.1:0",
"max_request_body_bytes": 16 * 1024 * 1024,
"max_response_body_bytes": 0,
"upstream_response_header_timeout": "120s",
@ -914,9 +954,11 @@ def build_proxy_config(
# startup. Pin the metrics listener to an ephemeral loopback
# port (``127.0.0.1:0``) so the metrics binding can't collide
# with the proxy listener regardless of what tunnel_port the
# operator chose. The daemon still emits metrics for any local
# scraper that knows where to look (via ``hermes egress
# status``); we just don't expose them to the public default.
# operator chose. NOTE: ``:0`` means the kernel picks a fresh
# random port each start and nothing records it — metrics are
# effectively disabled/undiscoverable at this pin. If we want
# scrapable metrics later, allocate a fixed port and surface it
# in ``ProxyStatus`` / ``hermes egress status``.
"metrics": {
"listen": "127.0.0.1:0",
},
@ -943,20 +985,19 @@ def build_proxy_config(
def ensure_audit_log(audit_path: Path) -> None:
"""Create the audit log file with private permissions (0o600).
Called from the wizard right before ``start_proxy``. Without this,
iron-proxy creates the file under the default umask the first time it
writes — meaning every host-side request history is potentially
world-readable. We pre-create the file empty with 0o600 so the daemon
inherits the tight permissions.
Called from the wizard right before ``start_proxy``. On the pinned
v0.39 the daemon never writes this file (no ``log.audit_path``
config field), so the pre-create is purely forward-compat: when the
pin moves to a version that supports a dedicated audit stream, the
file already exists with tight permissions and the daemon inherits
them instead of creating it under the default umask.
Raises :class:`RuntimeError` on any OSError (planted symlink,
immutable parent dir, full disk). The previous version of this
function logged a warning and returned silently — that's the bad
option, because the docstring's promise of "iron-proxy inherits
tight permissions" depends on us actually creating the file. When
the create fails, the caller MUST know about it so the wizard can
surface a clear error instead of printing "✓ audit log: ..." for a
file the daemon will create under the default umask.
immutable parent dir, full disk) so the caller can decide how to
surface it. The wizard treats this as a WARNING on v0.39 — the
file is non-load-bearing until the version bump — but the qualified
message keeps operators from wiring monitoring to a path that can't
exist.
"""
try:
@ -1509,7 +1550,16 @@ def start_proxy(
# We scope a Ctrl-C handler around the poll loop so an operator who
# hits Ctrl-C while waiting for ``hermes egress start`` doesn't leak
# an orphan with the port bound.
tunnel_port = _read_tunnel_port_from_config() or _DEFAULT_TUNNEL_PORT
#
# Probe the CONFIGURED bind host, not loopback unconditionally — on
# Linux the daemon binds the docker bridge gateway, where a loopback
# connect never succeeds and we'd kill a healthy daemon as "never
# came up".
listen_hp = _read_http_listen_from_config()
if listen_hp is not None:
probe_host, tunnel_port = listen_hp
else:
probe_host, tunnel_port = "127.0.0.1", _DEFAULT_TUNNEL_PORT
listening = False
def _interrupt_handler(_signum, _frame): # pragma: no cover - signal path
@ -1546,7 +1596,7 @@ def start_proxy(
f"iron-proxy exited immediately (code {proc.returncode}). "
f"Last log lines:\n{tail}"
)
if _port_listening("127.0.0.1", tunnel_port):
if _port_listening(probe_host, tunnel_port):
listening = True
break
if time.time() >= deadline:
@ -1582,7 +1632,7 @@ def start_proxy(
except FileNotFoundError:
pass
raise RuntimeError(
f"iron-proxy did not bind 127.0.0.1:{tunnel_port} within "
f"iron-proxy did not bind {probe_host}:{tunnel_port} within "
f"{_STARTUP_GRACE_SECONDS}s. Process was killed. "
f"Last log lines:\n{tail}"
)
@ -1758,14 +1808,24 @@ def _build_proxy_subprocess_env(
# selected. An operator on credential_source=bitwarden
# picked it specifically to get rotation; falling back
# to parent env reintroduces the bug class the mode
# is supposed to defeat.
raise RuntimeError(
f"Bitwarden refresh did not return secrets for "
f"{missing}. Either add the secrets to your BWS "
f"project, switch to credential_source: env via "
f"`hermes egress setup --no-bitwarden`, or set "
f"`proxy.allow_env_fallback: true` in config.yaml "
f"to opt into the legacy host-env fallback."
# is supposed to defeat. ``allow_env_fallback`` is the
# documented, deliberate opt-out — honor it here exactly
# as the empty-token branch below does (the error
# message tells operators to set it, so it must work).
if not (bitwarden_config or {}).get("allow_env_fallback"):
raise RuntimeError(
f"Bitwarden refresh did not return secrets for "
f"{missing}. Either add the secrets to your BWS "
f"project, switch to credential_source: env via "
f"`hermes egress setup --no-bitwarden`, or set "
f"`proxy.allow_env_fallback: true` in config.yaml "
f"to opt into the legacy host-env fallback."
)
logger.warning(
"Bitwarden refresh did not return secrets for %s — "
"falling back to host env for those names "
"(allow_env_fallback=true).",
missing,
)
# bws warnings are non-secret status messages (e.g. "no
# project found", "rate limited"), but the taint analyzer
@ -1897,7 +1957,12 @@ def get_status() -> ProxyStatus:
"""
status = ProxyStatus()
status.tunnel_port = _read_tunnel_port_from_config() or _DEFAULT_TUNNEL_PORT
listen_hp = _read_http_listen_from_config()
if listen_hp is not None:
probe_host, status.tunnel_port = listen_hp
else:
probe_host = "127.0.0.1"
status.tunnel_port = _DEFAULT_TUNNEL_PORT
binary = find_iron_proxy(install_if_missing=False)
if binary:
@ -1918,12 +1983,33 @@ def get_status() -> ProxyStatus:
pid = _read_pid()
if pid and _pid_alive(pid):
status.pid = pid
status.listening = _port_listening("127.0.0.1", status.tunnel_port)
# Probe the configured bind host — on Linux that's the docker
# bridge gateway, where a loopback connect would report a healthy
# daemon as "not listening".
status.listening = _port_listening(probe_host, status.tunnel_port)
return status
def _read_tunnel_port_from_config() -> Optional[int]:
listen = _read_http_listen_from_config()
if listen is None:
return None
return listen[1]
def _read_http_listen_from_config() -> Optional[Tuple[str, int]]:
"""Return ``(host, port)`` of the configured sandbox-facing listener.
Reads ``proxy.tunnel_listen`` — the CONNECT/MITM listener sandboxes
hit via ``HTTPS_PROXY`` — falling back to ``proxy.http_listen`` for
configs written before the tunnel/http listener-role split.
The bind host matters for liveness probes: on Linux the daemon binds
the docker bridge gateway (e.g. ``172.17.0.1``), where a loopback
connect would report "not listening" for a perfectly healthy daemon.
"""
cfg = _proxy_state_dir_ro() / "proxy.yaml"
if not cfg.exists():
return None
@ -1935,17 +2021,20 @@ def _read_tunnel_port_from_config() -> Optional[int]:
data = yaml.safe_load(cfg.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError):
return None
proxy_block = (data or {}).get("proxy") or {}
# The CLI/Docker side calls this "the tunnel port" because that's how
# sandboxes use it (HTTPS_PROXY), but on the iron-proxy side it's the
# http_listen — the HTTP-proxy listener handles both plain HTTP and the
# CONNECT method for HTTPS upstreams.
listen = ((data or {}).get("proxy") or {}).get("http_listen") or ""
# sandboxes use it (HTTPS_PROXY) — on the iron-proxy side it's the
# tunnel_listen (CONNECT + MITM). http_listen is the plain-HTTP
# forward listener on tunnel_port+1.
listen = proxy_block.get("tunnel_listen") or proxy_block.get("http_listen") or ""
if not isinstance(listen, str) or ":" not in listen:
return None
host, _, port_s = listen.rpartition(":")
try:
return int(listen.rsplit(":", 1)[1])
port = int(port_s)
except ValueError:
return None
return (host or "127.0.0.1", port)
def _port_listening(host: str, port: int) -> bool:

View file

@ -351,17 +351,19 @@ def cmd_setup(args: argparse.Namespace) -> int:
]
audit_log_path = ip._proxy_state_dir() / "audit.log"
# Pre-create the audit log with 0o600 so iron-proxy inherits private
# perms instead of letting the daemon create it under the default
# umask (potentially world-readable). Raises on failure (planted
# symlink, immutable parent, full disk) — the wizard must surface
# that rather than print "✓" for a file the daemon will create
# under a slacker umask.
# Pre-create the audit log with 0o600. On the pinned v0.39 the
# daemon does NOT write to this file (no ``log.audit_path`` field in
# its config schema) — it's reserved for the v0.40+ upgrade where
# per-request records start flowing. Because the file is
# non-load-bearing today, a pre-create failure (immutable parent,
# pre-existing foreign-owned file, full disk) is a WARNING, not a
# setup abort.
audit_log_ok = True
try:
ip.ensure_audit_log(audit_log_path)
except RuntimeError as exc:
console.print(f" [red]✗ {exc}[/red]")
return 1
audit_log_ok = False
console.print(f" [yellow]⚠ {exc}[/yellow]")
# Allow operator override of the deny list via
# ``proxy.upstream_deny_cidrs`` — but the default (None) gives a safe
@ -381,7 +383,12 @@ def cmd_setup(args: argparse.Namespace) -> int:
mappings_path = ip.write_mappings(mappings)
console.print(f" [green]✓[/green] config: {cfg_path}")
console.print(f" [green]✓[/green] mappings: {mappings_path}")
console.print(f" [green]✓[/green] audit log: {audit_log_path}")
if audit_log_ok:
console.print(
f" [green]✓[/green] audit log: {audit_log_path} "
f"[dim](reserved — not written by iron-proxy v0.39; "
f"per-request records land in iron-proxy.log)[/dim]"
)
# ------------------------------------------------------------------ enable
proxy_cfg["enabled"] = True
@ -451,6 +458,32 @@ def cmd_start(args: argparse.Namespace) -> int:
and bw_cfg is not None
and bool(bw_cfg.get("enabled"))
)
# Silent-degrade guard: the operator explicitly chose
# ``credential_source: bitwarden``, but secrets.bitwarden has since
# been disabled or removed. Proceeding would quietly start on host
# env — exactly the bug class the BW mode is meant to defeat. Refuse
# unless the documented escape hatch is set.
if credential_source == "bitwarden" and not refresh_bw:
if bool(proxy_cfg.get("allow_env_fallback", False)):
console.print(
"[yellow]⚠ credential_source=bitwarden but "
"secrets.bitwarden is disabled or missing — falling back "
"to host-env secrets (allow_env_fallback=true). Rotated "
"Bitwarden keys will NOT propagate.[/yellow]"
)
else:
console.print(
"[red]✗ Refusing to start: proxy.credential_source is "
"'bitwarden' but secrets.bitwarden is disabled or "
"missing.[/red]"
)
console.print(
" Re-enable it (`secrets.bitwarden.enabled: true`), switch "
"back to env credentials with `hermes egress setup "
"--no-bitwarden`, or set `proxy.allow_env_fallback: true` "
"to opt into the host-env fallback."
)
return 1
# Pass the proxy-side allow_env_fallback opt-in through to
# start_proxy. This is a deliberate, documented escape hatch: when
# set, the daemon silently falls back to host env if BWS is

View file

@ -212,8 +212,8 @@ def test_wizard_rendered_yaml_contains_deny_list(hermes_home, tmp_path):
def test_default_bind_is_loopback_not_zero_zero(tmp_path):
"""``http_listen`` must NOT be ``0.0.0.0:PORT`` or ``:PORT`` (latter is
INADDR_ANY). Loopback only by default."""
"""The sandbox-facing listeners must NOT be ``0.0.0.0:PORT`` or
``:PORT`` (latter is INADDR_ANY)."""
cfg = ip.build_proxy_config(
mappings=[_sample_mapping()],
@ -222,12 +222,16 @@ def test_default_bind_is_loopback_not_zero_zero(tmp_path):
tunnel_port=12345,
http_listen=["127.0.0.1:12345"], # explicit so test is deterministic
)
primary = cfg["proxy"]["http_listen"]
assert primary == "127.0.0.1:12345"
# Sentinel: confirm we didn't accidentally serialize a bare-port form
# like ":12345" (that's INADDR_ANY).
assert not primary.startswith(":")
assert "0.0.0.0" not in primary
# tunnel_listen is the CONNECT/MITM listener sandboxes hit via
# HTTPS_PROXY; http_listen is the plain-HTTP forward on port+1.
assert cfg["proxy"]["tunnel_listen"] == "127.0.0.1:12345"
assert cfg["proxy"]["http_listen"] == "127.0.0.1:12346"
for key in ("tunnel_listen", "http_listen", "https_listen"):
val = cfg["proxy"][key]
# Sentinel: confirm we didn't accidentally serialize a bare-port
# form like ":12345" (that's INADDR_ANY).
assert not val.startswith(":")
assert "0.0.0.0" not in val
# iron-proxy v0.39 doesn't support http_listens (plural). We
# deliberately do NOT emit that key — re-emitting it would cause
# the daemon to fail YAML unmarshal at start time.
@ -237,14 +241,14 @@ def test_default_bind_is_loopback_not_zero_zero(tmp_path):
)
def test_default_bind_uses_loopback_on_linux(tmp_path, monkeypatch):
def test_default_bind_uses_docker_bridge_on_linux(tmp_path, monkeypatch):
"""When http_listen isn't passed AND we're on Linux, the singular
http_listen field is the loopback bind. iron-proxy v0.39 only
supports one bind per daemon process — earlier versions of this
code emitted a plural http_listens list with the docker bridge
appended, but v0.39 rejects that key so we keep loopback only.
Sandboxes still reach the daemon via host.docker.internal which
Docker maps to the host gateway, so loopback-only is functional."""
http_listen field is the DOCKER BRIDGE bind — not loopback.
iron-proxy v0.39 only supports one bind per daemon process, and on
Linux ``host.docker.internal:host-gateway`` resolves to the bridge
gateway (172.17.0.1 by default), which a loopback-only daemon never
answers. Sandboxes must be able to reach the proxy from the
container's vantage point."""
monkeypatch.setattr(ip.platform, "system", lambda: "Linux")
monkeypatch.setattr(ip, "_detect_docker_bridge_ip", lambda: "172.17.0.1")
@ -254,12 +258,48 @@ def test_default_bind_uses_loopback_on_linux(tmp_path, monkeypatch):
ca_key=tmp_path / "ca.key",
tunnel_port=9090,
)
# Primary http_listen is loopback.
assert cfg["proxy"]["http_listen"] == "127.0.0.1:9090"
# The sandbox-facing CONNECT/MITM listener binds the bridge gateway —
# reachable from containers via host.docker.internal. Plain-HTTP
# forward listener rides on port+1, same host.
assert cfg["proxy"]["tunnel_listen"] == "172.17.0.1:9090"
assert cfg["proxy"]["http_listen"] == "172.17.0.1:9091"
# No http_listens (plural) — v0.39 rejects that key.
assert "http_listens" not in cfg["proxy"]
def test_default_bind_falls_back_to_loopback_without_bridge(tmp_path, monkeypatch):
"""On Linux without a detectable docker0 bridge (docker not
installed / not running), fall back to loopback rather than
refusing to bind."""
monkeypatch.setattr(ip.platform, "system", lambda: "Linux")
monkeypatch.setattr(ip, "_detect_docker_bridge_ip", lambda: None)
cfg = ip.build_proxy_config(
mappings=[_sample_mapping()],
ca_cert=tmp_path / "ca.crt",
ca_key=tmp_path / "ca.key",
tunnel_port=9090,
)
assert cfg["proxy"]["tunnel_listen"] == "127.0.0.1:9090"
assert cfg["proxy"]["http_listen"] == "127.0.0.1:9091"
def test_default_bind_is_loopback_on_macos(tmp_path, monkeypatch):
"""On Docker Desktop platforms host.docker.internal routes to the
host via VPNkit, so loopback is reachable from containers and is
the least-exposed bind."""
monkeypatch.setattr(ip.platform, "system", lambda: "Darwin")
cfg = ip.build_proxy_config(
mappings=[_sample_mapping()],
ca_cert=tmp_path / "ca.crt",
ca_key=tmp_path / "ca.key",
tunnel_port=9090,
)
assert cfg["proxy"]["tunnel_listen"] == "127.0.0.1:9090"
assert cfg["proxy"]["http_listen"] == "127.0.0.1:9091"
def test_metrics_listener_pinned_to_loopback_ephemeral(tmp_path):
"""iron-proxy v0.39's default metrics_listen is ``:9090``, which
collides with our default tunnel_port=9090. build_proxy_config MUST
@ -1455,3 +1495,102 @@ def test_persisted_nonce_roundtrip(hermes_home, monkeypatch):
def test_persisted_nonce_returns_none_when_missing(hermes_home):
"""No nonce file → None, callers fall back to argv0 basename."""
assert ip._read_persisted_nonce() is None
# ---------------------------------------------------------------------------
# v4 round (GodsBoy follow-up): bind-host-aware liveness probes +
# allow_env_fallback on the partial-secret path
# ---------------------------------------------------------------------------
def test_read_http_listen_from_config_returns_host_and_port(hermes_home):
"""_read_http_listen_from_config must surface the BIND HOST, not just
the port — on Linux the daemon binds the docker bridge gateway and a
hardcoded loopback probe would report a healthy daemon as dead."""
state = ip._proxy_state_dir()
(state / "proxy.yaml").write_text(
"proxy:\n http_listen: 172.17.0.1:9090\n", encoding="utf-8"
)
assert ip._read_http_listen_from_config() == ("172.17.0.1", 9090)
assert ip._read_tunnel_port_from_config() == 9090
def test_read_http_listen_from_config_missing_file(hermes_home):
assert ip._read_http_listen_from_config() is None
def test_get_status_probes_configured_bind_host(hermes_home, monkeypatch):
"""get_status must probe the configured bind host (e.g. the docker
bridge IP), not loopback unconditionally."""
state = ip._proxy_state_dir()
(state / "proxy.yaml").write_text(
"proxy:\n http_listen: 172.17.0.1:9123\n", encoding="utf-8"
)
(state / "ca.crt").write_text("cert")
ip._write_pidfile_safely(ip._pidfile(), 99999)
monkeypatch.setattr(ip, "_pid_alive", lambda pid: True)
monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: None)
probed = {}
def fake_probe(host, port):
probed["host"] = host
probed["port"] = port
return True
monkeypatch.setattr(ip, "_port_listening", fake_probe)
status = ip.get_status()
assert probed == {"host": "172.17.0.1", "port": 9123}
assert status.listening is True
assert status.tunnel_port == 9123
def test_partial_bitwarden_secrets_honor_allow_env_fallback(
hermes_home, monkeypatch,
):
"""The missing-secret branch's own error message tells operators to
set proxy.allow_env_fallback — so the flag must actually work there
(previously only the empty-token branch honored it)."""
ip.write_mappings([_sample_mapping("OPENROUTER_API_KEY")])
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-host-fallback")
import agent.secret_sources.bitwarden as bw
monkeypatch.setattr(
bw, "fetch_bitwarden_secrets", lambda **kw: ({}, []),
)
monkeypatch.setenv("BWS_ACCESS_TOKEN", "tok")
bw_cfg = {
"project_id": "proj",
"access_token_env": "BWS_ACCESS_TOKEN",
"allow_env_fallback": True,
}
env = ip._build_proxy_subprocess_env(
refresh_from_bitwarden=True, bitwarden_config=bw_cfg,
)
# Falls back to the host env value instead of raising.
assert env.get("OPENROUTER_API_KEY") == "sk-host-fallback"
def test_partial_bitwarden_secrets_raise_without_fallback(
hermes_home, monkeypatch,
):
"""Strict default: missing BWS secrets raise."""
ip.write_mappings([_sample_mapping("OPENROUTER_API_KEY")])
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-host")
import agent.secret_sources.bitwarden as bw
monkeypatch.setattr(
bw, "fetch_bitwarden_secrets", lambda **kw: ({}, []),
)
monkeypatch.setenv("BWS_ACCESS_TOKEN", "tok")
bw_cfg = {"project_id": "proj", "access_token_env": "BWS_ACCESS_TOKEN"}
with pytest.raises(RuntimeError, match="did not return secrets"):
ip._build_proxy_subprocess_env(
refresh_from_bitwarden=True, bitwarden_config=bw_cfg,
)

View file

@ -425,3 +425,88 @@ def test_setup_has_rotate_tokens_flag():
assert args.rotate_tokens is False
args = parser.parse_args(["setup", "--rotate-tokens"])
assert args.rotate_tokens is True
# ---------------------------------------------------------------------------
# v4 round: credential_source=bitwarden with secrets.bitwarden disabled
# must NOT silently degrade to host-env secrets
# ---------------------------------------------------------------------------
def test_cmd_start_refuses_when_bitwarden_mode_but_disabled(hermes_home, monkeypatch):
"""config keeps credential_source: bitwarden but secrets.bitwarden.enabled
later flips to false — cmd_start must refuse, not silently start on
host env (the silent-degrade class strict mode is meant to close)."""
from hermes_cli.config import load_config, save_config
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)
def must_not_call(**kw):
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
def test_cmd_start_bitwarden_disabled_proceeds_with_env_fallback(
hermes_home, monkeypatch,
):
"""Same scenario but proxy.allow_env_fallback=true is the documented
escape hatch — start proceeds (with a warning)."""
from hermes_cli.config import load_config, save_config
cfg = load_config()
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)
captured: dict = {}
def fake_start_proxy(**kw):
captured.update(kw)
s = ip.ProxyStatus()
s.pid = 4242
s.listening = True
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
# BW refresh is off (bitwarden disabled), running on env secrets.
assert captured.get("refresh_secrets_from_bitwarden") is False
def test_cmd_setup_audit_log_failure_is_warning_not_abort(hermes_home, monkeypatch):
"""On the pinned v0.39 the daemon never writes audit.log, so a
pre-create failure must not abort the wizard."""
monkeypatch.setattr(ip, "find_iron_proxy", lambda **kw: hermes_home / "iron-proxy")
monkeypatch.setattr(ip, "discover_provider_mappings", lambda **kw: [
ip.TokenMapping(
proxy_token="hermes-proxy-deadbeef",
real_env_name="OPENROUTER_API_KEY",
upstream_hosts=("openrouter.ai",),
),
])
monkeypatch.setattr(ip, "discover_uncovered_providers", lambda **kw: [])
def boom(path):
raise RuntimeError("could not pre-create audit log (synthetic)")
monkeypatch.setattr(ip, "ensure_audit_log", boom)
rc = proxy_cli.cmd_setup(_args())
assert rc == 0

View file

@ -105,6 +105,11 @@ def test_iron_proxy_swaps_authorization_header_end_to_end(hermes_home, monkeypat
# 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])
@ -125,19 +130,17 @@ def test_iron_proxy_swaps_authorization_header_end_to_end(hermes_home, monkeypat
pytest.fail("iron-proxy never started listening on the tunnel port")
# ----- request through the proxy ----------------------------------
# The fake upstream listens on plain HTTP (not HTTPS), so we use the
# proxy's tunnel for the CONNECT but talk plaintext to upstream via
# `--proxy-insecure` semantics: iron-proxy accepts HTTPS_PROXY-style
# CONNECT to any host on its allowlist. For a clean E2E we hit
# http://127.0.0.1:<port>/ which goes through the proxy as a plain
# HTTP forward (no MITM needed) and the secrets transform still fires
# on the Authorization header.
# 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}",
"-x", f"http://127.0.0.1:{tunnel_port + 1}",
"-H", f"Authorization: Bearer {proxy_token}",
f"http://127.0.0.1:{upstream_port}/",
],

View file

@ -449,15 +449,18 @@ def _egress_proxy_args_for_docker() -> tuple[list[str], dict[str, str], list[str
container_ca = "/etc/ssl/certs/hermes-egress-ca.crt"
volume_args = ["-v", f"{status.ca_cert_path}:{container_ca}:ro"]
# tunnel_port serves CONNECT (HTTPS); the plain-HTTP forward listener
# is on tunnel_port + 1 (see build_proxy_config's listener-role notes).
proxy_url = f"http://host.docker.internal:{status.tunnel_port}"
plain_http_url = f"http://host.docker.internal:{status.tunnel_port + 1}"
env_overrides: dict[str, str] = {
# HTTPS_PROXY / HTTP_PROXY are respected by curl, requests, urllib,
# httpx, node fetch, go default transport, etc. Lowercase variants
# are also set because some tools only look at one casing.
"HTTPS_PROXY": proxy_url,
"https_proxy": proxy_url,
"HTTP_PROXY": proxy_url,
"http_proxy": proxy_url,
"HTTP_PROXY": plain_http_url,
"http_proxy": plain_http_url,
# Loopback-only NO_PROXY so localhost dev servers inside the sandbox
# (test fixtures, local LLMs) don't get sent through the proxy.
"NO_PROXY": "127.0.0.1,localhost,::1",

View file

@ -124,7 +124,7 @@ hermes egress start
Poll loop (do-while shape):
while True:
if proc.poll() is not None: tail log + unlink pidfile + raise
if _port_listening("127.0.0.1", tunnel_port): break
if _port_listening(probe_host, tunnel_port): break # probe_host = configured bind host
if time.time() >= deadline: break (do-while: checked AFTER first probe)
time.sleep(0.1)
If not listening at exit: _kill_and_wait(proc) + unlink pidfile + raise.
@ -169,13 +169,15 @@ Regression: `test_subprocess_env_strips_unrelated_secrets`, `test_subprocess_env
### Bind policy
`_default_http_listen` returns loopback (and, on Linux, the docker bridge IP as a *second list entry the rendered yaml currently discards* — see below). Never `0.0.0.0`, never `:PORT` (INADDR_ANY).
`_default_http_listen` returns a single-element list: on Linux the docker bridge gateway IP (containers reach the proxy via `host.docker.internal:host-gateway`, which resolves to the bridge gateway — a loopback bind is unreachable from inside containers there); on macOS/Windows Docker Desktop, loopback (VPNkit routes `host.docker.internal` to the host). Linux without a detectable docker0 bridge falls back to loopback with a warning. Never `0.0.0.0`, never `:PORT` (INADDR_ANY).
`_detect_docker_bridge_ip` validates via `ipaddress.IPv4Address` and rejects `is_unspecified` / `is_loopback` / `is_multicast` / `is_reserved` / `is_link_local` / `is_global`. A hostile `ip` shim on PATH cannot inject `0.0.0.0`.
**v0.39 schema constraint:** the binary's `config.Proxy` struct has only a singular `http_listen` string field — there is no `http_listens` (plural) list, despite earlier comments in this module claiming otherwise. `build_proxy_config` emits only the first entry of `_default_http_listen`'s result; the second-bind path is dead code today. When the pinned `_IRON_PROXY_VERSION` is bumped to one that supports the plural form, re-enable the list-emit in `build_proxy_config` and the docker-bridge bind becomes live without further changes.
**v0.39 schema constraint and listener roles (verified live against the binary):** the binary's `config.Proxy` struct has only singular listener fields — there is no `http_listens` (plural) list. `tunnel_listen` is the CONNECT + MITM listener (what `HTTPS_PROXY` traffic hits); `http_listen` only handles absolute-form plain-HTTP forwards (a CONNECT sent to it is relayed upstream as a regular request and 400s). `build_proxy_config` therefore binds `tunnel_listen` on `tunnel_port` and `http_listen` on `tunnel_port + 1`, both on the platform bind host. The Docker backend sets `HTTPS_PROXY` to `tunnel_port` and `HTTP_PROXY` to `tunnel_port + 1`.
Regression: `test_default_bind_is_loopback_not_zero_zero` (asserts loopback bind AND that `http_listens` is NOT in the rendered yaml), `test_default_bind_uses_loopback_on_linux`, `test_detect_docker_bridge_ip_rejects_dangerous` (parametrized over 8 attack inputs).
The liveness probes (`start_proxy` poll loop, `get_status`) read the configured bind host via `_read_http_listen_from_config()` and probe THAT host — a hardcoded loopback probe would report a healthy bridge-bound daemon as dead.
Regression: `test_default_bind_is_loopback_not_zero_zero` (asserts no INADDR_ANY AND that `http_listens` is NOT in the rendered yaml), `test_default_bind_uses_docker_bridge_on_linux`, `test_default_bind_falls_back_to_loopback_without_bridge`, `test_default_bind_is_loopback_on_macos`, `test_detect_docker_bridge_ip_rejects_dangerous` (parametrized over 8 attack inputs).
### Metrics port collision
@ -191,7 +193,7 @@ Regression: `test_default_deny_cidrs_present_when_unspecified`, `test_default_de
### Audit log fail-loud
`ensure_audit_log` raises `RuntimeError` on any `OSError`. Swallowing the failure would let the daemon create the file under the default umask, defeating the privacy promise. `cmd_setup` catches the RuntimeError and surfaces a clear error to the operator.
`ensure_audit_log` raises `RuntimeError` on any `OSError`. On the pinned v0.39 the daemon never writes this file (no `log.audit_path` field), so `cmd_setup` treats the failure as a WARNING (the file is non-load-bearing until the version bump) and qualifies the success line as "reserved". When the pin moves to a version with `log.audit_path`, revisit: the pre-create becomes load-bearing for the 0o600-from-first-byte guarantee and the wizard should fail loud again.
**v0.39 schema constraint:** `log.audit_path` is NOT a field in iron-proxy v0.39's `config.Log` struct, so `build_proxy_config` accepts the `audit_log` kwarg but does NOT emit it into the rendered yaml. Per-request records on v0.39 land in `iron-proxy.log` alongside daemon-level events. The `audit.log` file is still pre-created at `0o600` with `O_NOFOLLOW` so the privacy contract holds when the pinned version is bumped to one that supports the separate stream.

View file

@ -140,16 +140,16 @@ To override: set `proxy.upstream_deny_cidrs` to your own list. To opt out entire
### Bind policy
The proxy binds **loopback only** (`127.0.0.1:<tunnel_port>`). It does NOT bind `0.0.0.0`. This means:
The proxy never binds `0.0.0.0`. The default bind is platform-specific because iron-proxy v0.39 supports only a **single bind per daemon process**:
- A LAN peer with a leaked proxy token cannot use it — the proxy is unreachable from the network.
- Containers reach the proxy via `host.docker.internal:9090`, which Docker maps to the host gateway via `--add-host=host.docker.internal:host-gateway` on Linux. On macOS / Windows Docker Desktop, Desktop manages the gateway itself.
- **Linux:** the docker bridge gateway (`172.17.0.1:<tunnel_port>` by default). Containers reach the proxy via `host.docker.internal`, which `--add-host=host.docker.internal:host-gateway` resolves to exactly this bridge gateway IP — a loopback-only bind would be unreachable from inside sandboxes. The bridge IP is an address on the host's `docker0` interface, so it is not exposed to the LAN; it IS reachable by other containers on the default bridge network, but requests still require a minted proxy token and an allowlisted upstream. If no docker bridge is detected (docker not installed/running), the bind falls back to loopback with a warning.
- **macOS / Windows Docker Desktop:** loopback (`127.0.0.1:<tunnel_port>`). Desktop's VPNkit routes `host.docker.internal` to the host, so loopback is reachable from containers and is the least-exposed choice.
iron-proxy v0.39 only supports a single bind per daemon process — earlier drafts of this integration emitted a plural `http_listens` list with the docker bridge IP appended for direct sandbox-to-bridge connectivity, but v0.39's YAML parser rejects that field. The `host.docker.internal -> host-gateway` mapping that Docker provides is sufficient: containers resolve the hostname to the bridge IP, then connect TO the host's loopback bind through it.
A LAN peer with a leaked proxy token cannot use the proxy — neither bind is reachable from the external network.
We also pin `metrics.listen: 127.0.0.1:0` so the daemon's built-in metrics server gets an ephemeral loopback port instead of its default `:9090` — otherwise it would fight `tunnel_port: 9090` for the same socket and the daemon would refuse to start with "address already in use".
We also pin `metrics.listen: 127.0.0.1:0` so the daemon's built-in metrics server gets an ephemeral loopback port instead of its default `:9090` — otherwise it would fight `tunnel_port: 9090` for the same socket and the daemon would refuse to start with "address already in use". Note the `:0` ephemeral port is random per start and not surfaced anywhere, so metrics are effectively disabled at this pin.
If a hostile `ip` shim earlier on PATH had been able to inject a non-private IPv4 here (`0.0.0.0`, a public address, multicast, link-local, etc.) the loopback fallback still applies — we never bind anything we couldn't validate via `ipaddress.IPv4Address` + `is_*` checks.
If a hostile `ip` shim earlier on PATH had been able to inject a non-private IPv4 as the bridge address (`0.0.0.0`, a public address, multicast, link-local, etc.) the loopback fallback still applies — we never bind anything we couldn't validate via `ipaddress.IPv4Address` + `is_*` checks.
## Uncovered providers
@ -322,7 +322,7 @@ On the currently pinned binary version (**v0.39.0**) iron-proxy writes ALL outpu
We still pre-create `~/.hermes/proxy/audit.log` at `0o600` with `O_NOFOLLOW` because:
1. It serves as a stable logrotate / fluent-bit / monitoring target — operators can wire downstream tooling to that path today, and when we bump the pinned version to one that supports `log.audit_path`, the records will start flowing without any operator-side reconfiguration.
1. It reserves the path for the future version bump: when the pinned version moves to one that supports `log.audit_path`, per-request records will start flowing there without operator-side reconfiguration. **Until then the file stays at 0 bytes — do not point monitoring, alerting, or forensics tooling at it yet.** Use `iron-proxy.log` for everything today.
2. The 0o600-from-first-byte guarantee defends against the upstream-fix-day where v0.40+ creates the file under its default umask if it doesn't already exist.
Until that version bump lands, treat `iron-proxy.log` as the source of truth for both audiences:
@ -369,7 +369,7 @@ When the Docker backend starts a container with `proxy.enabled: true` and the da
|---|---|
| `-v ~/.hermes/proxy/ca.crt:/etc/ssl/certs/hermes-egress-ca.crt:ro` | Read-only mount of the CA |
| `-e HTTPS_PROXY=http://host.docker.internal:9090` | Python httpx / curl / go default transport / Node fetch |
| `-e HTTP_PROXY=…` | curl + wget for plain HTTP (rare in modern stacks) |
| `-e HTTP_PROXY=http://host.docker.internal:9091` | curl + wget for plain HTTP — the plain-HTTP forward listener lives on `tunnel_port + 1` |
| `-e NO_PROXY=127.0.0.1,localhost,::1` | Loopback dev servers inside the sandbox bypass the proxy |
| `-e REQUESTS_CA_BUNDLE=…ca.crt` | Python `requests` |
| `-e SSL_CERT_FILE=…ca.crt` | Python `ssl` module / OpenSSL — **replaces** the system store |
@ -431,7 +431,7 @@ If the nonce check fails, the code falls back to matching `argv[0]` basename aga
- Agent dialing cloud metadata endpoints (`169.254.169.254`) — iron-proxy denies these by default via `upstream_deny_cidrs`, including the IPv4-mapped-v6 form `::ffff:169.254.169.254`.
- DNS rebinding through an allowlisted hostname to a private IP — the deny CIDRs are checked at connect time, not at allowlist time.
- Same-uid local processes reading the iron-proxy daemon's env to scrape secrets — only the env var names referenced by mappings are forwarded, not the full host env.
- A LAN peer with a leaked sandbox proxy token spending your API quota — the proxy binds loopback only, never `0.0.0.0` (containers reach it via `host.docker.internal -> host-gateway`).
- A LAN peer with a leaked sandbox proxy token spending your API quota — the proxy binds the docker bridge gateway (Linux) or loopback (Docker Desktop), never `0.0.0.0`, so it is unreachable from the external network.
**What it does NOT protect against:**
@ -484,10 +484,28 @@ Look at the last 20 lines of `~/.hermes/proxy/iron-proxy.log`. Common causes:
- Invalid `proxy.yaml` → run `hermes egress setup` to regenerate
- CA cert / key permissions wrong → `chmod 0o600 ~/.hermes/proxy/ca.key`
### "iron-proxy did not bind 127.0.0.1:9090 within 5s"
### "iron-proxy did not bind \<bind-host\>:9090 within 5s"
The daemon started but never bound the listener. Usually means the binary is wedged or doing something expensive at startup. Check `~/.hermes/proxy/iron-proxy.log`. The orphan process is killed automatically and the pidfile cleaned up so you can just retry `hermes egress start`.
### Sandbox times out connecting to the proxy (Linux)
The container resolves `host.docker.internal` to the docker bridge gateway and the proxy is bound there, but a host firewall (commonly `ufw` with default-deny INPUT) drops container→host traffic on `docker0`. Verify from a container:
```bash
docker run --rm --add-host host.docker.internal:host-gateway busybox \
nc -zv -w 3 host.docker.internal 9090
```
If that times out while `hermes egress status` shows `listening`, allow the bridge subnet in your firewall, e.g. for ufw:
```bash
sudo ufw allow in on docker0 to any port 9090 proto tcp
sudo ufw allow in on docker0 to any port 9091 proto tcp
```
(9091 = the plain-HTTP forward listener on `tunnel_port + 1`.)
### Sandbox sees `HTTP 403` from the proxy
The agent inside the sandbox tried to hit a host that isn't in `proxy.extra_allowed_hosts`. The 403 body explains which host. If you want to allow it, add to your config:
@ -553,7 +571,7 @@ Or watch in real-time:
tail -f ~/.hermes/proxy/iron-proxy.log | jq
```
When the pinned version moves to v0.40+ (which adds `log.audit_path`), per-request records will move to `~/.hermes/proxy/audit.log` and `iron-proxy.log` will hold only daemon-level events. The file at `audit.log` is pre-created today at `0o600` so any logrotate / monitoring tooling you wire to that path keeps working through the version bump without operator-side reconfig.
When the pinned version moves to v0.40+ (which adds `log.audit_path`), per-request records will move to `~/.hermes/proxy/audit.log` and `iron-proxy.log` will hold only daemon-level events. Until that bump, `audit.log` is an empty placeholder (pre-created at `0o600` so the future daemon inherits tight permissions) — wire your logrotate / monitoring tooling to `iron-proxy.log` today and plan to add `audit.log` after the version bump.
## Limitations (v1)
@ -563,7 +581,7 @@ When the pinned version moves to v0.40+ (which adds `log.audit_path`), per-reque
- The CA is a 10-year self-signed cert on first generation. Rotation requires `openssl genrsa ...` by hand (or wait for a follow-up that adds `hermes egress rotate-ca`).
- Token rotation does not auto-restart the daemon; after `--rotate-tokens` you must `hermes egress stop && hermes egress start` and then restart running sandboxes.
- iron-proxy in-memory secret zeroisation is upstream-controlled. Same-uid attackers with `/proc/<pid>/mem` read access can read swapped-in secrets from the daemon's memory.
- iron-proxy v0.39 only supports a **single bind per daemon** and combines daemon + per-request records into a single log stream. The integration is designed to upgrade cleanly: the moment upstream adds `proxy.http_listens` (plural) and `log.audit_path`, both wire in automatically without changing operator configs.
- iron-proxy v0.39 only supports a **single bind per daemon** (we bind the docker bridge gateway on Linux, loopback on Docker Desktop) and combines daemon + per-request records into a single log stream. When upstream adds `proxy.http_listens` (plural) and `log.audit_path`, a version bump can wire in multi-bind and the dedicated audit stream.
## See also