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

@ -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