From 7ab9f6ee5c1caa8eeba5a38564a75446cf242d46 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:39:37 +0200 Subject: [PATCH] fix: bypass proxies in ctl startup health probe --- ctl.sh | 3 +- scripts/lib/health_probe.sh | 46 ++++++++++++------ tests/test_ctl_foreign_server_guard.py | 67 ++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 16 deletions(-) diff --git a/ctl.sh b/ctl.sh index fe26185f..95164be8 100755 --- a/ctl.sh +++ b/ctl.sh @@ -626,8 +626,7 @@ start_cmd() { rm -f "${PID_FILE}" "${STATE_FILE}" return 1 fi - if http_proxy='' https_proxy='' HTTP_PROXY='' HTTPS_PROXY='' \ - hermes_webui_probe_health "${probe_host}" "${CTL_PORT}" "/health" 1 >/dev/null 2>&1; then + if hermes_webui_probe_health "${probe_host}" "${CTL_PORT}" "/health" 1 direct >/dev/null 2>&1; then healthy=1 break fi diff --git a/scripts/lib/health_probe.sh b/scripts/lib/health_probe.sh index 89141231..477adbaa 100644 --- a/scripts/lib/health_probe.sh +++ b/scripts/lib/health_probe.sh @@ -58,31 +58,49 @@ _hermes_webui_warn_self_signed() { "$1" >&2 } -# _hermes_webui_http_get +# _hermes_webui_http_get [direct] # mode: "insecure" disables certificate verification, anything else verifies. +# direct: "direct" bypasses every configured proxy. # Prints the response body to stdout; returns the underlying client exit code. _hermes_webui_http_get() { - local url="$1" max_time="$2" mode="$3" + local url="$1" max_time="$2" mode="$3" direct="${4:-}" if command -v curl >/dev/null 2>&1; then if [[ "${mode}" == "insecure" ]]; then - curl -fsS -k --max-time "${max_time}" "${url}" 2>/dev/null + if [[ "${direct}" == "direct" ]]; then + curl -fsS -k --noproxy '*' --max-time "${max_time}" "${url}" 2>/dev/null + else + curl -fsS -k --max-time "${max_time}" "${url}" 2>/dev/null + fi else - curl -fsS --max-time "${max_time}" "${url}" 2>/dev/null + if [[ "${direct}" == "direct" ]]; then + curl -fsS --noproxy '*' --max-time "${max_time}" "${url}" 2>/dev/null + else + curl -fsS --max-time "${max_time}" "${url}" 2>/dev/null + fi fi return $? elif command -v wget >/dev/null 2>&1; then if [[ "${mode}" == "insecure" ]]; then - wget -qO- --no-check-certificate "--timeout=${max_time}" --tries=1 "${url}" 2>/dev/null + if [[ "${direct}" == "direct" ]]; then + wget -qO- --no-proxy --no-check-certificate "--timeout=${max_time}" --tries=1 "${url}" 2>/dev/null + else + wget -qO- --no-check-certificate "--timeout=${max_time}" --tries=1 "${url}" 2>/dev/null + fi else - wget -qO- "--timeout=${max_time}" --tries=1 "${url}" 2>/dev/null + if [[ "${direct}" == "direct" ]]; then + wget -qO- --no-proxy "--timeout=${max_time}" --tries=1 "${url}" 2>/dev/null + else + wget -qO- "--timeout=${max_time}" --tries=1 "${url}" 2>/dev/null + fi fi return $? fi return 127 } -# hermes_webui_probe_health [path] [max_time] -# Prints the response body to stdout on success; warnings go to stderr. +# hermes_webui_probe_health [path] [max_time] [direct] +# Pass "direct" to bypass every configured proxy for all probe attempts. +# Prints the response body to stdout; warnings go to stderr. # Returns 0 if the server answered, 1 otherwise. # # Side effect: sets the global _HERMES_WEBUI_PROBE_SCHEME to the scheme that @@ -91,14 +109,14 @@ _hermes_webui_http_get() { # back to plain HTTP when the cert/key are unloadable — so the configured # scheme can be https:// while the live server speaks http://. hermes_webui_probe_health() { - local host="$1" port="$2" path="${3:-/health}" max_time="${4:-2}" + local host="$1" port="$2" path="${3:-/health}" max_time="${4:-2}" direct="${5:-}" local scheme body scheme="$(hermes_webui_probe_scheme)" local http_url="http://${host}:${port}${path}" if [[ "${scheme}" == "http" ]]; then - if body="$(_hermes_webui_http_get "${http_url}" "${max_time}" "")"; then + if body="$(_hermes_webui_http_get "${http_url}" "${max_time}" "" "${direct}")"; then _HERMES_WEBUI_PROBE_SCHEME="http" printf '%s' "${body}" return 0 @@ -111,20 +129,20 @@ hermes_webui_probe_health() { if _hermes_webui_truthy "${HERMES_WEBUI_TLS_INSECURE_PROBE:-}"; then # Explicit opt-in: skip verification, stay silent by contract. - if body="$(_hermes_webui_http_get "${https_url}" "${max_time}" "insecure")"; then + if body="$(_hermes_webui_http_get "${https_url}" "${max_time}" "insecure" "${direct}")"; then _HERMES_WEBUI_PROBE_SCHEME="https" printf '%s' "${body}" return 0 fi else # 1) Verified HTTPS. - if body="$(_hermes_webui_http_get "${https_url}" "${max_time}" "")"; then + if body="$(_hermes_webui_http_get "${https_url}" "${max_time}" "" "${direct}")"; then _HERMES_WEBUI_PROBE_SCHEME="https" printf '%s' "${body}" return 0 fi # 2) Self-signed fallback: verification failed, retry unverified + warn. - if body="$(_hermes_webui_http_get "${https_url}" "${max_time}" "insecure")"; then + if body="$(_hermes_webui_http_get "${https_url}" "${max_time}" "insecure" "${direct}")"; then _hermes_webui_warn_self_signed "${https_url}" _HERMES_WEBUI_PROBE_SCHEME="https" printf '%s' "${body}" @@ -133,7 +151,7 @@ hermes_webui_probe_health() { fi # 3) server.py may have fallen back to plain HTTP (cert/key unloadable). - if body="$(_hermes_webui_http_get "${http_url}" "${max_time}" "")"; then + if body="$(_hermes_webui_http_get "${http_url}" "${max_time}" "" "${direct}")"; then _HERMES_WEBUI_PROBE_SCHEME="http" printf '%s' "${body}" return 0 diff --git a/tests/test_ctl_foreign_server_guard.py b/tests/test_ctl_foreign_server_guard.py index b095cf05..3a24ff8e 100644 --- a/tests/test_ctl_foreign_server_guard.py +++ b/tests/test_ctl_foreign_server_guard.py @@ -430,6 +430,73 @@ def test_port_guard_ignores_http_proxy_env(tmp_path): _stop_proc(server) +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards") +def test_start_health_probe_ignores_all_proxy_env(tmp_path): + """The startup watch must reach its own local health server directly. + + Unlike the preflight ownership guard above, this drives the post-launch + health loop: a dead ALL_PROXY must not make ctl wait out its grace period + and print the misleading health-timeout note. + """ + port = _free_port() + health_server = tmp_path / "health-server" + health_server.write_text( + textwrap.dedent( + """ + #!/usr/bin/env python3 + import sys + from http.server import BaseHTTPRequestHandler, HTTPServer + + class HealthHandler(BaseHTTPRequestHandler): + def do_GET(self): + body = b'{"status": "ok"}' + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + HTTPServer(("127.0.0.1", int(sys.argv[-1])), HealthHandler).serve_forever() + """ + ).lstrip(), + encoding="utf-8", + ) + health_server.chmod(0o755) + + started_pid = None + try: + result = run_ctl( + tmp_path, + "start", + env=_guard_env( + HERMES_WEBUI_PORT=str(port), + HERMES_WEBUI_PYTHON=str(health_server), + HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT="1", + # A live local server must still win when both proxy spellings + # route clients to a dead endpoint. + ALL_PROXY="http://127.0.0.1:1", + all_proxy="http://127.0.0.1:1", + NO_PROXY="", + no_proxy="", + ), + timeout=15, + ) + combined = result.stdout + result.stderr + pid_file = tmp_path / ".hermes" / "webui.pid" + if pid_file.exists(): + started_pid = int(pid_file.read_text().strip()) + assert result.returncode == 0, combined + assert "/health did not respond" not in combined + finally: + if started_pid: + try: + os.kill(started_pid, 9) + except ProcessLookupError: + pass + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards") def test_ipv6_host_probe_builds_bracketed_url(tmp_path): """HERMES_WEBUI_HOST='::1' must probe http://[::1]:port — the unbracketed