diff --git a/examples/example.c b/examples/example.c index 3627e0e53..befcc261a 100644 --- a/examples/example.c +++ b/examples/example.c @@ -240,6 +240,27 @@ has_arg(int argc, char **argv, const char *arg) return false; } +/** + * Builds a proxy URL from a scheme, optional credentials, host, and a port + * read from an environment variable (with a fallback default). + */ +static void +make_proxy_url(char *buf, size_t buf_size, const char *scheme, + const char *credentials, const char *host, const char *port_env_var, + const char *default_port) +{ + const char *port = getenv(port_env_var); + if (!port) + port = default_port; + + if (credentials) { + snprintf( + buf, buf_size, "%s://%s@%s:%s", scheme, credentials, host, port); + } else { + snprintf(buf, buf_size, "%s://%s:%s", scheme, host, port); + } +} + #if defined(SENTRY_PLATFORM_WINDOWS) && !defined(__MINGW32__) \ && !defined(__MINGW64__) @@ -515,21 +536,31 @@ main(int argc, char **argv) } if (has_arg(argc, argv, "http-proxy")) { - sentry_options_set_proxy(options, "http://127.0.0.1:8080"); + char proxy_url[128]; + make_proxy_url(proxy_url, sizeof(proxy_url), "http", NULL, "127.0.0.1", + "SENTRY_TEST_PROXY_PORT", "8080"); + sentry_options_set_proxy(options, proxy_url); } if (has_arg(argc, argv, "http-proxy-auth")) { - sentry_options_set_proxy( - options, "http://user:password@127.0.0.1:8080"); + char proxy_url[128]; + make_proxy_url(proxy_url, sizeof(proxy_url), "http", "user:password", + "127.0.0.1", "SENTRY_TEST_PROXY_PORT", "8080"); + sentry_options_set_proxy(options, proxy_url); } if (has_arg(argc, argv, "http-proxy-ipv6")) { - sentry_options_set_proxy(options, "http://[::1]:8080"); + char proxy_url[128]; + make_proxy_url(proxy_url, sizeof(proxy_url), "http", NULL, "[::1]", + "SENTRY_TEST_PROXY_PORT", "8080"); + sentry_options_set_proxy(options, proxy_url); } if (has_arg(argc, argv, "proxy-empty")) { sentry_options_set_proxy(options, ""); } - if (has_arg(argc, argv, "socks5-proxy")) { - sentry_options_set_proxy(options, "socks5://127.0.0.1:1080"); + char proxy_url[128]; + make_proxy_url(proxy_url, sizeof(proxy_url), "socks5", NULL, + "127.0.0.1", "SENTRY_TEST_PROXY_PORT", "1080"); + sentry_options_set_proxy(options, proxy_url); } if (has_arg(argc, argv, "crashpad-wait-for-upload")) { diff --git a/tests/proxy.py b/tests/proxy.py index 39c4edc6c..94c933fe0 100644 --- a/tests/proxy.py +++ b/tests/proxy.py @@ -1,3 +1,5 @@ +import contextlib +import re import os import socket import subprocess @@ -8,48 +10,77 @@ from tests.assertions import assert_no_proxy_request +@contextlib.contextmanager +def closed_port(): + """Bind a port and hold it open without listening. + Connections are guaranteed to be refused, and no other process can claim it.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + yield s.getsockname()[1] + + def setup_proxy_env_vars(port): - os.environ["http_proxy"] = f"http://localhost:{port}" - os.environ["https_proxy"] = f"http://localhost:{port}" + os.environ["http_proxy"] = f"http://127.0.0.1:{port}" + os.environ["https_proxy"] = f"http://127.0.0.1:{port}" def cleanup_proxy_env_vars(): - del os.environ["http_proxy"] - del os.environ["https_proxy"] + os.environ.pop("http_proxy", None) + os.environ.pop("https_proxy", None) + + +def _parse_listening_port(proxy_process, timeout=10): + """Read the 'Proxy server listening at ...' line from mitmdump stdout + and extract the port. This consumes only the first line; subsequent + stdout (request logs) remains available via proxy_process.stdout.""" + deadline = time.monotonic() + timeout + buf = b"" + while time.monotonic() < deadline: + ch = proxy_process.stdout.read(1) + if not ch: + break + buf += ch + if ch == b"\n": + match = re.search(rb"listening at .+:(\d+)", buf) + if match: + return int(match.group(1)) + buf = b"" + if proxy_process.poll() is not None: + break + pytest.fail( + f"mitmdump did not report a listening port within {timeout}s. Output so far: {buf!r}" + ) + + +def start_mitmdump(proxy_type, proxy_auth: str = None, listen_host: str = "127.0.0.1"): + """Start mitmdump with OS-assigned port (--listen-port 0). Returns (process, port).""" + proxy_command = [ + "mitmdump", + "--set", + f"listen_host={listen_host}", + "--listen-port", + "0", + ] + if proxy_type == "socks5-proxy": + proxy_command += ["--mode", "socks5"] + + if proxy_auth: + proxy_command += ["-v", "--proxyauth", proxy_auth] + + # mitmdump (also written in python) often buffers output long enough so that we don't catch it + env = {**os.environ, "PYTHONUNBUFFERED": "1"} + proxy_process = subprocess.Popen( + proxy_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env + ) -def is_proxy_running(host, port): try: - with socket.create_connection((host, port), timeout=1): - return True - except ConnectionRefusedError: - return False - - -def start_mitmdump(proxy_type, proxy_auth: str = None): - # start mitmdump from terminal - proxy_process = None - if proxy_type == "http-proxy": - proxy_command = ["mitmdump"] - if proxy_auth: - proxy_command += ["-v", "--proxyauth", proxy_auth] - proxy_process = subprocess.Popen( - proxy_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True - ) - time.sleep(5) # Give mitmdump some time to start - if not is_proxy_running("localhost", 8080): - pytest.fail("mitmdump (HTTP) did not start correctly") - elif proxy_type == "socks5-proxy": - proxy_command = ["mitmdump", "--mode", "socks5"] - if proxy_auth: - proxy_command += ["-v", "--proxyauth", proxy_auth] - proxy_process = subprocess.Popen( - proxy_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True - ) - time.sleep(5) # Give mitmdump some time to start - if not is_proxy_running("localhost", 1080): - pytest.fail("mitmdump (SOCKS5) did not start correctly") - return proxy_process + port = _parse_listening_port(proxy_process) + except Exception: + proxy_process.terminate() + proxy_process.wait() + raise + return proxy_process, port def proxy_test_finally( @@ -66,8 +97,8 @@ def proxy_test_finally( # Give mitmdump some time to get a response from the mock server time.sleep(0.5) proxy_process.terminate() - proxy_process.wait() - stdout, stderr = proxy_process.communicate() + stdout_bytes, _ = proxy_process.communicate() + stdout = stdout_bytes.decode("utf-8", errors="replace") if expected_proxy_logsize == 0: # don't expect any incoming requests to make it through the proxy proxy_log_assert(stdout) diff --git a/tests/test_integration_crashpad.py b/tests/test_integration_crashpad.py index 619ed0fa5..3db4b4931 100644 --- a/tests/test_integration_crashpad.py +++ b/tests/test_integration_crashpad.py @@ -60,14 +60,19 @@ def test_crashpad_capture(cmake, httpserver): def _setup_crashpad_proxy_test(cmake, httpserver, proxy): - proxy_process = start_mitmdump(proxy) if proxy else None + if proxy: + proxy_process, port = start_mitmdump(proxy) + else: + proxy_process, port = None, None tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "crashpad"}) env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver, proxy_host=True)) + if port is not None: + env["SENTRY_TEST_PROXY_PORT"] = str(port) httpserver.expect_oneshot_request("/api/123456/minidump/").respond_with_data("OK") - return env, proxy_process, tmp_path + return env, proxy_process, tmp_path, port def test_crashpad_crash_proxy_env(cmake, httpserver): @@ -75,11 +80,13 @@ def test_crashpad_crash_proxy_env(cmake, httpserver): pytest.skip("mitmdump is not installed") proxy_process = None # store the proxy process to terminate it later - setup_proxy_env_vars(port=8080) try: - env, proxy_process, tmp_path = _setup_crashpad_proxy_test( + env, proxy_process, tmp_path, port = _setup_crashpad_proxy_test( cmake, httpserver, "http-proxy" ) + setup_proxy_env_vars(port=port) + env["http_proxy"] = f"http://127.0.0.1:{port}" + env["https_proxy"] = f"http://127.0.0.1:{port}" with httpserver.wait(timeout=10) as waiting: run( @@ -100,11 +107,13 @@ def test_crashpad_crash_proxy_env_port_incorrect(cmake, httpserver): pytest.skip("mitmdump is not installed") proxy_process = None # store the proxy process to terminate it later - setup_proxy_env_vars(port=8081) try: - env, proxy_process, tmp_path = _setup_crashpad_proxy_test( + env, proxy_process, tmp_path, port = _setup_crashpad_proxy_test( cmake, httpserver, "http-proxy" ) + setup_proxy_env_vars(port=port + 1) + env["http_proxy"] = f"http://127.0.0.1:{port + 1}" + env["https_proxy"] = f"http://127.0.0.1:{port + 1}" with pytest.raises(AssertionError): with httpserver.wait(timeout=10): @@ -125,11 +134,15 @@ def test_crashpad_proxy_set_empty(cmake, httpserver): pytest.skip("mitmdump is not installed") proxy_process = None # store the proxy process to terminate it later - setup_proxy_env_vars(port=8080) # we start the proxy but expect it to remain unused try: - env, proxy_process, tmp_path = _setup_crashpad_proxy_test( + env, proxy_process, tmp_path, port = _setup_crashpad_proxy_test( cmake, httpserver, "http-proxy" ) + setup_proxy_env_vars( + port=port + ) # we start the proxy but expect it to remain unused + env["http_proxy"] = f"http://127.0.0.1:{port}" + env["https_proxy"] = f"http://127.0.0.1:{port}" with httpserver.wait(timeout=10) as waiting: run( @@ -152,11 +165,12 @@ def test_crashpad_proxy_https_not_http(cmake, httpserver): proxy_process = None # store the proxy process to terminate it later # we start the proxy but expect it to remain unused (dsn is http, so shouldn't use https proxy) - os.environ["https_proxy"] = f"http://localhost:8080" try: - env, proxy_process, tmp_path = _setup_crashpad_proxy_test( + env, proxy_process, tmp_path, port = _setup_crashpad_proxy_test( cmake, httpserver, "http-proxy" ) + os.environ["https_proxy"] = f"http://127.0.0.1:{port}" + env["https_proxy"] = f"http://127.0.0.1:{port}" with httpserver.wait(timeout=10) as waiting: run( @@ -169,7 +183,7 @@ def test_crashpad_proxy_https_not_http(cmake, httpserver): assert waiting.result finally: - del os.environ["https_proxy"] + os.environ.pop("https_proxy", None) proxy_test_finally(1, httpserver, proxy_process, expected_proxy_logsize=0) @@ -196,7 +210,7 @@ def test_crashpad_crash_proxy(cmake, httpserver, run_args, proxy_running): try: proxy_to_start = run_args[0] if proxy_running else None - env, proxy_process, tmp_path = _setup_crashpad_proxy_test( + env, proxy_process, tmp_path, port = _setup_crashpad_proxy_test( cmake, httpserver, proxy_to_start ) diff --git a/tests/test_integration_proxy.py b/tests/test_integration_proxy.py index 136cbbd09..7dac03a09 100644 --- a/tests/test_integration_proxy.py +++ b/tests/test_integration_proxy.py @@ -7,31 +7,36 @@ from . import ( make_dsn, run, - SENTRY_VERSION, -) -from .proxy import ( - setup_proxy_env_vars, - cleanup_proxy_env_vars, - start_mitmdump, - proxy_test_finally, ) from .assertions import ( assert_failed_proxy_auth_request, ) from .conditions import has_http +from .proxy import ( + closed_port, + start_mitmdump, + proxy_test_finally, +) pytestmark = pytest.mark.skipif(not has_http, reason="tests need http") -def _setup_http_proxy_test(cmake, httpserver, proxy, proxy_auth=None): - proxy_process = start_mitmdump(proxy, proxy_auth) if proxy else None +def _setup_http_proxy_test( + cmake, httpserver, proxy, proxy_auth=None, listen_host="127.0.0.1" +): + if proxy: + proxy_process, port = start_mitmdump(proxy, proxy_auth, listen_host=listen_host) + else: + proxy_process, port = None, None tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "none"}) env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver, proxy_host=True)) + if port is not None: + env["SENTRY_TEST_PROXY_PORT"] = str(port) httpserver.expect_request("/api/123456/envelope/").respond_with_data("OK") - return env, proxy_process, tmp_path + return env, proxy_process, tmp_path, port def test_proxy_from_env(cmake, httpserver): @@ -39,11 +44,12 @@ def test_proxy_from_env(cmake, httpserver): pytest.skip("mitmdump is not installed") proxy_process = None # store the proxy process to terminate it later - setup_proxy_env_vars(port=8080) try: - env, proxy_process, tmp_path = _setup_http_proxy_test( + env, proxy_process, tmp_path, port = _setup_http_proxy_test( cmake, httpserver, "http-proxy" ) + env["http_proxy"] = f"http://127.0.0.1:{port}" + env["https_proxy"] = f"http://127.0.0.1:{port}" run( tmp_path, @@ -53,7 +59,6 @@ def test_proxy_from_env(cmake, httpserver): ) finally: - cleanup_proxy_env_vars() proxy_test_finally(1, httpserver, proxy_process) @@ -62,11 +67,13 @@ def test_proxy_from_env_port_incorrect(cmake, httpserver): pytest.skip("mitmdump is not installed") proxy_process = None # store the proxy process to terminate it later - setup_proxy_env_vars(port=8081) try: - env, proxy_process, tmp_path = _setup_http_proxy_test( + env, proxy_process, tmp_path, port = _setup_http_proxy_test( cmake, httpserver, "http-proxy" ) + # Set env vars with a wrong port (offset by 1 from the actual proxy port) + env["http_proxy"] = f"http://127.0.0.1:{port + 1}" + env["https_proxy"] = f"http://127.0.0.1:{port + 1}" run( tmp_path, @@ -76,7 +83,6 @@ def test_proxy_from_env_port_incorrect(cmake, httpserver): ) finally: - cleanup_proxy_env_vars() proxy_test_finally(0, httpserver, proxy_process) @@ -86,7 +92,7 @@ def test_proxy_auth(cmake, httpserver): proxy_process = None # store the proxy process to terminate it later try: - env, proxy_process, tmp_path = _setup_http_proxy_test( + env, proxy_process, tmp_path, port = _setup_http_proxy_test( cmake, httpserver, "http-proxy", proxy_auth="user:password" ) @@ -94,7 +100,11 @@ def test_proxy_auth(cmake, httpserver): tmp_path, "sentry_example", ["log", "capture-event", "http-proxy-auth"], - env=dict(os.environ, SENTRY_DSN=make_dsn(httpserver, proxy_host=True)), + env=dict( + os.environ, + SENTRY_DSN=make_dsn(httpserver, proxy_host=True), + SENTRY_TEST_PROXY_PORT=str(port), + ), ) finally: proxy_test_finally( @@ -111,7 +121,7 @@ def test_proxy_auth_incorrect(cmake, httpserver): proxy_process = None # store the proxy process to terminate it later try: - env, proxy_process, tmp_path = _setup_http_proxy_test( + env, proxy_process, tmp_path, port = _setup_http_proxy_test( cmake, httpserver, "http-proxy", proxy_auth="wrong:wrong" ) @@ -119,7 +129,11 @@ def test_proxy_auth_incorrect(cmake, httpserver): tmp_path, "sentry_example", ["log", "capture-event", "http-proxy-auth"], - env=dict(os.environ, SENTRY_DSN=make_dsn(httpserver, proxy_host=True)), + env=dict( + os.environ, + SENTRY_DSN=make_dsn(httpserver, proxy_host=True), + SENTRY_TEST_PROXY_PORT=str(port), + ), ) finally: proxy_test_finally( @@ -136,8 +150,8 @@ def test_proxy_ipv6(cmake, httpserver): proxy_process = None # store the proxy process to terminate it later try: - env, proxy_process, tmp_path = _setup_http_proxy_test( - cmake, httpserver, "http-proxy" + env, proxy_process, tmp_path, port = _setup_http_proxy_test( + cmake, httpserver, "http-proxy", listen_host="::1" ) run( @@ -156,11 +170,13 @@ def test_proxy_set_empty(cmake, httpserver): pytest.skip("mitmdump is not installed") proxy_process = None # store the proxy process to terminate it later - setup_proxy_env_vars(port=8080) # we start the proxy but expect it to remain unused try: - env, proxy_process, tmp_path = _setup_http_proxy_test( + env, proxy_process, tmp_path, port = _setup_http_proxy_test( cmake, httpserver, "http-proxy" ) + # we start the proxy but expect it to remain unused + env["http_proxy"] = f"http://127.0.0.1:{port}" + env["https_proxy"] = f"http://127.0.0.1:{port}" run( tmp_path, @@ -170,7 +186,6 @@ def test_proxy_set_empty(cmake, httpserver): ) finally: - cleanup_proxy_env_vars() proxy_test_finally(1, httpserver, proxy_process, expected_proxy_logsize=0) @@ -179,12 +194,12 @@ def test_proxy_https_not_http(cmake, httpserver): pytest.skip("mitmdump is not installed") proxy_process = None # store the proxy process to terminate it later - # we start the proxy but expect it to remain unused (dsn is http, so shouldn't use https proxy) - os.environ["https_proxy"] = f"http://localhost:8080" try: - env, proxy_process, tmp_path = _setup_http_proxy_test( + # we start the proxy but expect it to remain unused (dsn is http, so shouldn't use https proxy) + env, proxy_process, tmp_path, port = _setup_http_proxy_test( cmake, httpserver, "http-proxy" ) + env["https_proxy"] = f"http://127.0.0.1:{port}" run( tmp_path, @@ -194,7 +209,6 @@ def test_proxy_https_not_http(cmake, httpserver): ) finally: - del os.environ["https_proxy"] proxy_test_finally(1, httpserver, proxy_process, expected_proxy_logsize=0) @@ -221,19 +235,32 @@ def test_capture_proxy(cmake, httpserver, run_args, proxy_running): try: proxy_to_start = run_args[0] if proxy_running else None - env, proxy_process, tmp_path = _setup_http_proxy_test( + env, proxy_process, tmp_path, port = _setup_http_proxy_test( cmake, httpserver, proxy_to_start ) - run( - tmp_path, - "sentry_example", - ["log", "capture-event"] - + run_args, # only passes if given proxy is running - env=dict(os.environ, SENTRY_DSN=make_dsn(httpserver, proxy_host=True)), - ) + + def do_run(effective_port): + port_env = {"SENTRY_TEST_PROXY_PORT": str(effective_port)} + run( + tmp_path, + "sentry_example", + ["log", "capture-event"] + + run_args, # only passes if given proxy is running + env=dict( + os.environ, + SENTRY_DSN=make_dsn(httpserver, proxy_host=True), + **port_env, + ), + ) + if proxy_running: + do_run(port) expected_logsize = 1 else: + # Hold a port open without listening — connections are refused and + # no other process can claim it while the test is running. + with closed_port() as refused_port: + do_run(refused_port) expected_logsize = 0 finally: proxy_test_finally(expected_logsize, httpserver, proxy_process)