Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions hermes_cli/proxy_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,24 @@ def register_cli(parent_parser: argparse.ArgumentParser) -> None:
disable.set_defaults(func=cmd_disable)

cfg = sub.add_parser("config", help="Print the generated proxy.yaml path")

health = sub.add_parser(
"health",
help="Check whether the iron-proxy is running and accepting connections",
)
health.add_argument(
"--watch",
action="store_true",
help="Poll every second until the proxy is healthy (or Ctrl-C)",
)
health.add_argument(
"--timeout",
type=int,
default=0,
metavar="SECONDS",
help="Exit non-zero if proxy is not healthy within SECONDS (0 = no timeout)",
)
health.set_defaults(func=cmd_health)
cfg.set_defaults(func=cmd_config)


Expand Down Expand Up @@ -765,6 +783,70 @@ def yn(value: bool) -> str:
return "\n".join(lines)


def cmd_health(args: argparse.Namespace) -> int:
"""Check whether iron-proxy is running and accepting connections.

Exit codes
----------
0 proxy is up and listening
1 proxy process exists but port is not accepting connections
2 proxy is not running or not configured
"""
import time

console = Console()
watch = getattr(args, "watch", False)
timeout = getattr(args, "timeout", 0)
deadline = (time.monotonic() + timeout) if timeout > 0 else None

def _check() -> int:
status = ip.get_status()
# Report the address the daemon actually binds, not a hardcoded
# loopback. On Linux the proxy binds the docker bridge gateway
# (e.g. 172.17.0.1) so containers can reach it; probing/printing
# 127.0.0.1 there would call a healthy daemon dead. get_status()
# already probes this host for `status.listening`; we surface the
# same host here so the reported address matches what was tested.
listen = ip._read_http_listen_from_config()
host = listen[0] if listen else "127.0.0.1"
endpoint = f"{host}:{status.tunnel_port}"
if not status.pid:
console.print("[red]✗[/red] iron-proxy is not running")
return 2
if not status.listening:
console.print(
f"[yellow]⚠[/yellow] iron-proxy pid {status.pid} exists "
f"but {endpoint} is not accepting connections"
)
return 1
console.print(
f"[green]✓[/green] iron-proxy pid {status.pid} "
f"listening on {endpoint}"
)
return 0

# A positive --timeout implies polling even without --watch, so the
# documented `hermes egress health --timeout 30` waits for the deadline
# instead of returning after a single probe. Reuses the same deadline
# and poll loop as --watch below.
if not watch and timeout <= 0:
return _check()

# --watch (or a positive --timeout): poll every second until healthy or
# the deadline expires
try:
while True:
rc = _check()
if rc == 0:
return 0
if deadline is not None and time.monotonic() >= deadline:
return rc
time.sleep(1)
except KeyboardInterrupt:
console.print()
return 2


def cmd_status(args: argparse.Namespace) -> int:
console = Console()
cfg = load_config()
Expand Down
145 changes: 145 additions & 0 deletions tests/hermes_cli/test_proxy_cli_health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Tests for ``hermes egress health`` subcommand."""
from __future__ import annotations

import argparse
from contextlib import contextmanager
from unittest.mock import MagicMock, patch

from hermes_cli.proxy_cli import cmd_health


def _args(watch=False, timeout=0):
ns = argparse.Namespace()
ns.watch = watch
ns.timeout = timeout
return ns


def _make_status(pid, listening, tunnel_port=9090):
s = MagicMock()
s.pid = pid
s.listening = listening
s.tunnel_port = tunnel_port
return s


@contextmanager
def _env(status, listen=("127.0.0.1", 9090), capture_console=False):
"""Patch the iron-proxy hooks cmd_health depends on.

``listen`` is the ``(host, port)`` the daemon binds, as reported by
``_read_http_listen_from_config`` — the source of truth for the probe
host (loopback on macOS/Windows, the docker bridge gateway on Linux).
"""
patches = [
patch("hermes_cli.proxy_cli.ip.get_status", return_value=status),
patch(
"hermes_cli.proxy_cli.ip._read_http_listen_from_config",
return_value=listen,
),
]
console = None
if capture_console:
console = MagicMock()
patches.append(
patch("hermes_cli.proxy_cli.Console", return_value=console)
)
started = [p.start() for p in patches]
try:
yield console
finally:
for p in patches:
p.stop()
del started


def _printed(console) -> str:
return "\n".join(str(c.args[0]) for c in console.print.call_args_list if c.args)


class TestCmdHealth:
def test_healthy_returns_0(self):
with _env(_make_status(pid=1234, listening=True)):
assert cmd_health(_args()) == 0

def test_not_running_returns_2(self):
with _env(_make_status(pid=None, listening=False)):
assert cmd_health(_args()) == 2

def test_pid_exists_not_listening_returns_1(self):
with _env(_make_status(pid=1234, listening=False)):
assert cmd_health(_args()) == 1

def test_watch_returns_0_when_healthy(self):
with _env(_make_status(pid=1234, listening=True)):
assert cmd_health(_args(watch=True)) == 0

def test_watch_timeout_returns_nonzero(self):
with _env(_make_status(pid=None, listening=False)):
with patch("time.sleep"):
assert cmd_health(_args(watch=True, timeout=1)) != 0

def test_timeout_without_watch_polls_until_healthy(self):
"""A positive --timeout without --watch must enter the polling loop,
not return after a single probe. An initially-down proxy that comes
up before the deadline should be reported healthy (exit 0) — this is
the documented `hermes egress health --timeout N` standalone contract.
"""
down = _make_status(pid=None, listening=False)
up = _make_status(pid=1234, listening=True)
with _env(down):
with patch(
"hermes_cli.proxy_cli.ip.get_status",
side_effect=[down, down, up],
) as gs, patch("time.sleep"):
assert cmd_health(_args(watch=False, timeout=30)) == 0
# Proves it polled repeatedly rather than probing once and
# returning the initial down state.
assert gs.call_count == 3

def test_timeout_without_watch_returns_nonzero_at_deadline(self):
"""--timeout without --watch that never becomes healthy must poll
until the deadline and then exit non-zero (not hang, not exit 0)."""
down = _make_status(pid=None, listening=False)
# Fake monotonic clock advancing 1s per call so the timeout=3
# deadline is crossed after a few polls instead of blocking.
ticks = iter([1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
with _env(down):
with patch(
"hermes_cli.proxy_cli.ip.get_status", return_value=down
) as gs, patch("time.sleep"), patch(
"time.monotonic", side_effect=lambda: next(ticks)
):
assert cmd_health(_args(watch=False, timeout=3)) == 2
# Polled more than once before giving up at the deadline.
assert gs.call_count > 1

def test_watch_keyboard_interrupt_returns_2(self):
with _env(_make_status(pid=None, listening=False)):
with patch("time.sleep", side_effect=KeyboardInterrupt):
assert cmd_health(_args(watch=True)) == 2

def test_reports_configured_bind_host_not_loopback(self):
"""On Linux the daemon binds the docker bridge; the health output
must report that address, never a hardcoded 127.0.0.1 (which would
be an unreachable probe target for a perfectly healthy daemon)."""
status = _make_status(pid=1234, listening=True, tunnel_port=9070)
with _env(status, listen=("172.17.0.1", 9070), capture_console=True) as console:
assert cmd_health(_args()) == 0
out = _printed(console)
assert "172.17.0.1:9070" in out
assert "127.0.0.1" not in out

def test_not_listening_message_uses_configured_host(self):
status = _make_status(pid=1234, listening=False, tunnel_port=9070)
with _env(status, listen=("172.17.0.1", 9070), capture_console=True) as console:
assert cmd_health(_args()) == 1
assert "172.17.0.1:9070" in _printed(console)

def test_falls_back_to_loopback_when_config_absent(self):
"""No proxy.yaml (helper returns None) → loopback is the correct
display default, matching get_status()'s own fallback."""
status = _make_status(pid=1234, listening=True, tunnel_port=9090)
with _env(status, listen=None, capture_console=True) as console:
assert cmd_health(_args()) == 0
assert "127.0.0.1:9090" in _printed(console)
6 changes: 6 additions & 0 deletions website/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,12 @@ hermes egress reload # hot-reload the ruleset in-place (no res
hermes egress status # binary + config + pid + listening + mappings
hermes egress status --show-tokens # print proxy tokens in full (default: redacted)

hermes egress health # one-shot liveness check
# (exit 0 = up, 1 = port closed, 2 = stopped)
hermes egress health --watch # poll every second until healthy (or Ctrl-C)
hermes egress health --timeout 30 # exit non-zero if not healthy within N seconds
# (implies polling — usable without --watch)

hermes egress disable # flip proxy.enabled = false (does not stop a running proxy)
hermes egress config # print the path to proxy.yaml for inspection
```
Expand Down
12 changes: 12 additions & 0 deletions website/docs/user-guide/egress/iron-proxy.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,11 @@ hermes egress status # binary + config + pid + listening state
hermes egress status --show-tokens # print proxy tokens in full
# (default: redacted prefix + suffix only)

hermes egress health # one-shot liveness check
# (exit 0 = up, 1 = port closed, 2 = stopped)
hermes egress health --watch # poll until healthy (scripts / cron pre-checks)
hermes egress health --timeout 30 # fail if not healthy within N seconds

hermes egress disable # flip proxy.enabled = false
# (does not stop a running proxy)

Expand Down Expand Up @@ -424,6 +429,13 @@ If the nonce check fails, the code falls back to matching `argv[0]` basename aga

## Failure modes

Use `hermes egress health` to quickly diagnose proxy state before debugging further:

```bash
hermes egress health # exit 0 = healthy, 1 = port closed, 2 = stopped
hermes egress health --watch --timeout 30 # wait up to 30s for the proxy to come up
```

- **Binary not installed, `auto_install: true`** — first `hermes egress setup` or `hermes egress start` downloads it. SHA-256 verified against the upstream `checksums.txt`.
- **Binary not installed, `auto_install: false`** — `start` fails with a clear message pointing to manual install.
- **`enabled: true` but proxy not running** — with `enforce_on_docker: true` (default), Docker sandbox creation refuses to start with an explanatory error. With `enforce_on_docker: false`, it falls back to direct outbound with real creds and logs a warning.
Expand Down
Loading