You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
`_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).
173
173
174
174
`_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`.
175
175
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`.
`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.
**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.
@@ -268,7 +278,7 @@ The Docker implementation is ~150 lines; expect similar volume for Modal / Dayto
268
278
269
279
### Subscribing to per-request audit events
270
280
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).
Copy file name to clipboardExpand all lines: website/docs/user-guide/egress/iron-proxy.md
+33-26Lines changed: 33 additions & 26 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -140,13 +140,16 @@ To override: set `proxy.upstream_deny_cidrs` to your own list. To opt out entire
140
140
141
141
### Bind policy
142
142
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:
144
144
145
145
- 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.
148
147
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.
150
153
151
154
## Uncovered providers
152
155
@@ -308,17 +311,24 @@ Everything iron-proxy maintains lives in `~/.hermes/proxy/`:
308
311
| `mappings.json.rotated-*` | `0o600` | Backups created by `--rotate-tokens` |
309
312
| `iron-proxy.pid` | `0o600` | PID of the running daemon |
| `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
313
320
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.
315
322
316
-
### Audit log vs daemon log
323
+
We still pre-create `~/.hermes/proxy/audit.log` at `0o600` with `O_NOFOLLOW` because:
317
324
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.
319
327
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.
- Per-request records (CONNECT to allowlisted upstream, secret swap fired, allowlist denial). Forensics + compliance.
322
332
323
333
Both files are appended to across restarts. Rotate them with logrotate if you care about disk usage on long-lived hosts.
324
334
@@ -335,10 +345,10 @@ Both files are appended to across restarts. Rotate them with logrotate if you ca
335
345
│ - HTTPS_PROXY│ │ swaps secret │ │ │
336
346
└──────────────┘ └──────────────┘ └─────────────┘
337
347
│
338
-
│ structured per-request audit log
348
+
│ daemon + per-request log (combined on v0.39)
339
349
▼
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)
342
352
```
343
353
344
354
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
347
357
4. iron-proxy mints a leaf cert signed by our CA for `openrouter.ai`, terminates the TLS connection, inspects the request.
348
358
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.
349
359
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).
351
361
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.
353
363
354
364
### CA distribution into the sandbox
355
365
@@ -421,13 +431,13 @@ If the nonce check fails, the code falls back to matching `argv[0]` basename aga
421
431
- 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`.
422
432
- DNS rebinding through an allowlisted hostname to a private IP — the deny CIDRs are checked at connect time, not at allowlist time.
423
433
- 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`).
425
435
426
436
**What it does NOT protect against:**
427
437
428
438
- 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.
429
439
- 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.
431
441
- 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).
432
442
- 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.
433
443
@@ -531,23 +541,19 @@ hermes egress start
531
541
532
542
### Inspecting per-request behavior
533
543
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:
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.
- 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`).
558
564
- Token rotation does not auto-restart the daemon; after `--rotate-tokens` you must `hermes egress stop && hermes egress start` and then restart running sandboxes.
559
565
- 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.
0 commit comments