Skip to content

Commit bde66ef

Browse files
committed
docs(egress): align user + dev docs with iron-proxy v0.39 actual behavior
The previous docs round (906b1da) described the integration the way we wanted it to work — `http_listens` plural with a docker bridge bind, dedicated `audit.log` for per-request JSON records. Live testing against the real v0.39.0 binary in [905ce58] surfaced that neither field exists in v0.39's config schema, and the docs were making promises the daemon couldn't keep. This commit walks every claim in the docs back to what the binary actually does today, while keeping the upgrade path explicit so the docs stay coherent when the pinned `_IRON_PROXY_VERSION` bumps: website/docs/user-guide/egress/iron-proxy.md - Bind policy section: rewritten. Was "loopback + docker bridge IP on Linux"; now "loopback only" with an explicit explanation that v0.39 only supports one bind per daemon and that host.docker.internal -> host-gateway mapping is what sandboxes use to reach the loopback bind. - Bind policy section adds a note on the metrics-port pin that the previous round of docs didn't even mention. - State directory layout table: `audit.log` description rewritten to acknowledge it's a pre-created sentinel for future binary versions, NOT something the v0.39 daemon writes to. - New section "Logging on iron-proxy v0.39" replaces the old "Audit log vs daemon log" section. Explicitly tells operators the daemon log is the single source of truth for both audiences on v0.39, with the upgrade path called out. - Data-flow diagram step 7: rewritten to send per-request records to `iron-proxy.log` on v0.39 with cross-link to the new logging section. - Diagram caption updated. - Security-model "allowlisted-host exfiltration" line: "audit log captures" -> "daemon log captures". - Security-model "LAN peer leak" line: removed the docker-bridge claim. - Troubleshooting section's per-request-inspection recipes: rewritten to use `iron-proxy.log` and explain when the split stream will land. - Limitations list gets a new bullet calling out the single-bind + combined-log v0.39 constraints + the auto-upgrade posture. website/docs/developer-guide/egress-internals.md - Bind policy invariant: documents the singular `http_listen` v0.39 schema constraint + dead-code-until-upgrade status of the bridge-bind path. - New "Metrics port collision" invariant documenting why `metrics.listen: 127.0.0.1:0` is non-negotiable. - Audit log fail-loud invariant adds the v0.39 schema constraint note + the new `test_audit_log_kwarg_does_not_inject_audit_path_v039` regression test. - "Subscribing to per-request audit events" section updated to send watchers at `iron-proxy.log` for v0.39 with the upgrade pivot called out. website/docs/reference/cli-commands.md - Diagnostic shortcut for tailing the audit log: `tail audit.log | jq` -> `tail iron-proxy.log | jq` with the v0.39 note inline. Build verification: - `npx docusaurus build` succeeds across all three locales (en + zh-Hans + ko). - New `#logging-on-iron-proxy-v039` anchor lands in the rendered HTML and the in-page cross-references resolve. - No new broken anchors introduced (pre-existing warnings on unrelated zh-Hans pages are unchanged). - No leftover stale `#audit-log-vs-daemon-log` or `#http_listens` references anywhere on the egress pages.
1 parent 905ce58 commit bde66ef

3 files changed

Lines changed: 48 additions & 31 deletions

File tree

website/docs/developer-guide/egress-internals.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -169,11 +169,19 @@ Regression: `test_subprocess_env_strips_unrelated_secrets`, `test_subprocess_env
169169

170170
### Bind policy
171171

172-
`_default_http_listen` returns loopback + (Linux only) the docker bridge IP. Never `0.0.0.0`, never `:PORT` (INADDR_ANY).
172+
`_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).
173173

174174
`_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`.
175175

176-
Regression: `test_default_bind_is_loopback_not_zero_zero`, `test_detect_docker_bridge_ip_rejects_dangerous` (parametrized over 8 attack inputs).
176+
**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.
177+
178+
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).
179+
180+
### Metrics port collision
181+
182+
`metrics.listen` defaults to `:9090` in iron-proxy v0.39 — the SAME port as Hermes's default `tunnel_port: 9090`. `build_proxy_config` MUST explicitly pin `metrics.listen: 127.0.0.1:0` so the metrics binding gets an ephemeral loopback port that can never collide with the proxy listener regardless of operator-chosen `tunnel_port`.
183+
184+
Regression: `test_metrics_listener_pinned_to_loopback_ephemeral`.
177185

178186
### Default deny CIDRs
179187

@@ -185,7 +193,9 @@ Regression: `test_default_deny_cidrs_present_when_unspecified`, `test_default_de
185193

186194
`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.
187195

188-
Regression: `test_ensure_audit_log_raises_on_immutable_parent`.
196+
**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.
197+
198+
Regression: `test_ensure_audit_log_raises_on_immutable_parent`, `test_audit_log_kwarg_does_not_inject_audit_path_v039`.
189199

190200
### Bitwarden mode fail-loud
191201

@@ -268,7 +278,7 @@ The Docker implementation is ~150 lines; expect similar volume for Modal / Dayto
268278

269279
### Subscribing to per-request audit events
270280

271-
iron-proxy writes line-delimited JSON to `~/.hermes/proxy/audit.log`. A plugin / external watcher can tail the file and react to allowlist denials, secret swaps, or upstream errors. The schema is documented at [docs.iron.sh/audit](https://docs.iron.sh/audit) (link).
281+
iron-proxy writes line-delimited JSON to `~/.hermes/proxy/iron-proxy.log` on the currently pinned v0.39 (daemon + per-request records combined; see "Logging on iron-proxy v0.39" in the user guide). A plugin / external watcher can tail that file and react to allowlist denials, secret swaps, or upstream errors. When the pinned version is bumped to one that supports `log.audit_path`, the per-request stream moves to `audit.log` and watchers wired to that path go live without operator action. The schema is documented at [docs.iron.sh/audit](https://docs.iron.sh/audit) (link).
272282

273283
## Testing
274284

website/docs/reference/cli-commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -513,7 +513,7 @@ hermes egress stop && hermes egress start
513513
hermes egress status # current state in one view
514514
cat ~/.hermes/proxy/proxy.yaml # the rendered iron-proxy config
515515
tail -20 ~/.hermes/proxy/iron-proxy.log # daemon-level diagnostics
516-
tail -f ~/.hermes/proxy/audit.log | jq # per-request audit log (line-delimited JSON)
516+
tail -f ~/.hermes/proxy/iron-proxy.log | jq # daemon + per-request log (line-delimited JSON; v0.39 combines both streams)
517517
```
518518

519519
Common failure modes + recovery are covered in [Egress proxy → Troubleshooting](../user-guide/egress/iron-proxy.md#troubleshooting).

website/docs/user-guide/egress/iron-proxy.md

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -140,13 +140,16 @@ To override: set `proxy.upstream_deny_cidrs` to your own list. To opt out entire
140140

141141
### Bind policy
142142

143-
The proxy binds **loopback only** (`127.0.0.1:<tunnel_port>`), plus the docker bridge gateway IP on Linux (auto-detected via `ip -4 addr show docker0`, typically `172.17.0.1`). It does NOT bind `0.0.0.0`. This means:
143+
The proxy binds **loopback only** (`127.0.0.1:<tunnel_port>`). It does NOT bind `0.0.0.0`. This means:
144144

145145
- A LAN peer with a leaked proxy token cannot use it — the proxy is unreachable from the network.
146-
- Containers reach the proxy via `host.docker.internal:9090`, which Docker maps to the bridge gateway via `--add-host=host.docker.internal:host-gateway`.
147-
- On macOS / Windows Docker Desktop, Desktop manages the gateway itself, so a single loopback bind is enough.
146+
- 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.
148147

149-
If the `ip` binary returns a suspicious address (anything that isn't a private IPv4 — `0.0.0.0`, public addresses, multicast, link-local, etc.) the bridge bind is skipped with a warning. This defends against a hostile `ip` shim on PATH being able to inject `0.0.0.0` and re-open INADDR_ANY.
148+
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.
149+
150+
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".
151+
152+
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.
150153

151154
## Uncovered providers
152155

@@ -308,17 +311,24 @@ Everything iron-proxy maintains lives in `~/.hermes/proxy/`:
308311
| `mappings.json.rotated-*` | `0o600` | Backups created by `--rotate-tokens` |
309312
| `iron-proxy.pid` | `0o600` | PID of the running daemon |
310313
| `iron-proxy.nonce` | `0o600` | Per-start nonce for PID-recycle defense |
311-
| `iron-proxy.log` | `0o600` | Daemon stdout/stderr (startup, bind errors, shutdown) |
312-
| `audit.log` | `0o600` | Structured per-request JSON log |
314+
| `iron-proxy.log` | `0o600` | Daemon stdout/stderr — **includes per-request records on v0.39** |
315+
| `audit.log` | `0o600` | Reserved for the dedicated per-request audit stream on future binary versions; pre-created so the privacy contract holds when upstream wires it in |
316+
317+
The CA private key is the most sensitive file. It's created with `0o600` from the first byte (no umask-window TOCTOU) and `O_NOFOLLOW` so a same-uid attacker can't redirect it via a planted symlink. The pidfile, nonce file, daemon log, and audit log get the same treatment.
318+
319+
### Logging on iron-proxy v0.39
313320

314-
The CA private key and the per-request audit log are the most sensitive files; both are created with `0o600` from the first byte (no umask-window TOCTOU) and `O_NOFOLLOW` so a same-uid attacker can't redirect them via a planted symlink. The pidfile and nonce file get the same treatment.
321+
On the currently pinned binary version (**v0.39.0**) iron-proxy writes ALL output — daemon-level diagnostics AND per-request records — to **`~/.hermes/proxy/iron-proxy.log`**. v0.39's `config.Log` struct doesn't have a separate `audit_path` field, so we can't route per-request records to a dedicated stream there.
315322

316-
### Audit log vs daemon log
323+
We still pre-create `~/.hermes/proxy/audit.log` at `0o600` with `O_NOFOLLOW` because:
317324

318-
Two separate files, two separate audiences:
325+
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.
326+
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.
319327

320-
- `audit.log` is **per-request**. Every CONNECT through the proxy is recorded as a structured JSON entry: timestamp, sandbox source, upstream host, request size, response status, secret-swap fired (yes/no), processing time. Forensics + compliance.
321-
- `iron-proxy.log` is **daemon-level**. Startup banner, bind errors, shutdown reason, transform errors. Operations + troubleshooting.
328+
Until that version bump lands, treat `iron-proxy.log` as the source of truth for both audiences:
329+
330+
- Daemon-level events (startup banner, bind errors, shutdown reason, transform errors). Operations + troubleshooting.
331+
- Per-request records (CONNECT to allowlisted upstream, secret swap fired, allowlist denial). Forensics + compliance.
322332

323333
Both files are appended to across restarts. Rotate them with logrotate if you care about disk usage on long-lived hosts.
324334

@@ -335,10 +345,10 @@ Both files are appended to across restarts. Rotate them with logrotate if you ca
335345
│ - HTTPS_PROXY│ │ swaps secret │ │ │
336346
└──────────────┘ └──────────────┘ └─────────────┘
337347
338-
structured per-request audit log
348+
daemon + per-request log (combined on v0.39)
339349
340-
~/.hermes/proxy/audit.log
341-
(daemon stdout/stderr at ~/.hermes/proxy/iron-proxy.log)
350+
~/.hermes/proxy/iron-proxy.log
351+
(~/.hermes/proxy/audit.log reserved for v0.40+ split stream)
342352
```
343353
344354
1. Sandbox makes an HTTPS request, e.g. `POST https://openrouter.ai/v1/chat/completions` with `Authorization: Bearer hermes-proxy-openrouter-…` (the proxy token, not the real key).
@@ -347,9 +357,9 @@ Both files are appended to across restarts. Rotate them with logrotate if you ca
347357
4. iron-proxy mints a leaf cert signed by our CA for `openrouter.ai`, terminates the TLS connection, inspects the request.
348358
5. The `secrets` transform matches the proxy-token string in the `Authorization` header and substitutes the real `OPENROUTER_API_KEY` value, sourced from iron-proxy's own environment.
349359
6. Request is re-encrypted and forwarded to OpenRouter.
350-
7. Every request is logged as a structured JSON entry to `~/.hermes/proxy/audit.log`. Daemon-level diagnostics (startup, bind errors, shutdown) go to `~/.hermes/proxy/iron-proxy.log` separately.
360+
7. The request is logged to `~/.hermes/proxy/iron-proxy.log` on v0.39. When the pinned binary version supports the split stream (v0.40+), per-request records will flow to `~/.hermes/proxy/audit.log` and daemon-level diagnostics will stay in `iron-proxy.log`. See [Logging on iron-proxy v0.39](#logging-on-iron-proxy-v039).
351361
352-
A request to a non-allowlisted host (e.g. `https://attacker.example.com/leak?key=...`) is rejected with HTTP 403 before any bytes leave the host. The denial is recorded in `audit.log` with the upstream host and the source sandbox.
362+
A request to a non-allowlisted host (e.g. `https://attacker.example.com/leak?key=...`) is rejected with HTTP 403 before any bytes leave the host. The denial is recorded in `iron-proxy.log` with the upstream host and the source sandbox.
353363
354364
### CA distribution into the sandbox
355365
@@ -421,13 +431,13 @@ If the nonce check fails, the code falls back to matching `argv[0]` basename aga
421431
- 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`.
422432
- DNS rebinding through an allowlisted hostname to a private IP — the deny CIDRs are checked at connect time, not at allowlist time.
423433
- 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.
424-
- A LAN peer with a leaked sandbox proxy token spending your API quota — the proxy binds loopback + docker bridge only, not `0.0.0.0`.
434+
- 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`).
425435
426436
**What it does NOT protect against:**
427437
428438
- A compromised host process. If the agent process itself is compromised, real keys in the host's `~/.hermes/.env` are exposed regardless. This is a defense-in-depth feature for *sandbox* compromise, not host compromise.
429439
- Sandbox processes that bypass `HTTPS_PROXY` by using a raw socket. The proxy can't intercept what doesn't route to it. Node.js is partially mitigated via `NODE_OPTIONS=--use-openssl-ca` (see caveat above).
430-
- Allowlisted-host data exfiltration. If `api.openai.com` is allowed, an agent could embed exfil data in a request body to that host. The audit log captures this but doesn't prevent it.
440+
- Allowlisted-host data exfiltration. If `api.openai.com` is allowed, an agent could embed exfil data in a request body to that host. The daemon log captures the request happened but doesn't prevent it.
431441
- Uncovered providers (Anthropic native, AWS Bedrock, Azure OpenAI, Gemini). Their env vars stay in the sandbox; if you enable them, those credentials bypass the proxy entirely. See [Uncovered providers](#uncovered-providers).
432442
- iron-proxy in-memory secret zeroisation. The Go binary holds swapped-in real credentials in process memory; a core-dump or `/proc/<pid>/mem` read from a same-uid attacker would expose them. Out of scope for this layer.
433443
@@ -531,23 +541,19 @@ hermes egress start
531541

532542
### Inspecting per-request behavior
533543

534-
The audit log is line-delimited JSON. Grep for a specific upstream:
544+
On the pinned binary version (**v0.39**) both daemon-level events and per-request records land in `~/.hermes/proxy/iron-proxy.log`. The format is line-delimited JSON. Grep for a specific upstream:
535545

536546
```bash
537-
grep '"host":"openrouter.ai"' ~/.hermes/proxy/audit.log | tail -20
547+
grep '"upstream":"openrouter.ai"' ~/.hermes/proxy/iron-proxy.log | tail -20
538548
```
539549

540550
Or watch in real-time:
541551

542552
```bash
543-
tail -f ~/.hermes/proxy/audit.log | jq
553+
tail -f ~/.hermes/proxy/iron-proxy.log | jq
544554
```
545555

546-
Daemon-level errors (bind failures, transform errors, shutdown reasons) go to `iron-proxy.log`, not `audit.log`:
547-
548-
```bash
549-
tail -50 ~/.hermes/proxy/iron-proxy.log
550-
```
556+
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.
551557

552558
## Limitations (v1)
553559

@@ -557,6 +563,7 @@ tail -50 ~/.hermes/proxy/iron-proxy.log
557563
- 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`).
558564
- Token rotation does not auto-restart the daemon; after `--rotate-tokens` you must `hermes egress stop && hermes egress start` and then restart running sandboxes.
559565
- 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.
566+
- 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.
560567

561568
## See also
562569

0 commit comments

Comments
 (0)