From 172df519feb08db2540817f53a5ac14830ee19c6 Mon Sep 17 00:00:00 2001 From: FENG <223868679+MakerFlyFly@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:19:43 +0000 Subject: [PATCH 1/2] fix(egress-gateway): start proxy with explicit arguments Pass CLI arguments to Proxy(input_args=...) and use its context manager with the zero-argument sleep loop supported by proxy.py 2.4.x. Preserve dependency-free help through lazy package exports, add isolated startup and lifecycle regressions, and document usage and verification. Generated-by: Codex (GPT-6) --- tools/egress-gateway/README.md | 38 +++- .../src/egress_gateway/__init__.py | 16 +- .../egress-gateway/src/egress_gateway/cli.py | 7 +- tools/egress-gateway/tests/test_cli.py | 172 ++++++++++++++++++ 4 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 tools/egress-gateway/tests/test_cli.py diff --git a/tools/egress-gateway/README.md b/tools/egress-gateway/README.md index 9bea7f7a6..4cc4bedce 100644 --- a/tools/egress-gateway/README.md +++ b/tools/egress-gateway/README.md @@ -11,6 +11,8 @@ - [Point tools at it](#point-tools-at-it) - [The allowlist](#the-allowlist) - [Test](#test) + - [Automated tests](#automated-tests) + - [Manual verification](#manual-verification) - [Caveat — host-level, not payload-level](#caveat--host-level-not-payload-level) @@ -40,7 +42,7 @@ how-to. - **Runtime:** Python 3.11+ run via `uv` (no system install needed). - **Dependencies:** `proxy.py` (`>=2.4,<3`) — the forward-proxy runtime, - resolved by `uv` from PyPI; the allowlist policy is the only first-party code. + resolved by `uv` from PyPI. - **CLIs:** None beyond the runtime. - **Credentials / auth:** None. - **Network:** Binds a loopback listen socket (default `127.0.0.1:8899`) and @@ -48,10 +50,14 @@ how-to. context. Needs a writable `$HOME` (or a `HOME` override) for runtime state under `~/.proxy`. Default allowlist mirrors the sandbox's curated domains (ASF infra, GitHub, Google APIs, PyPI). -- **Optional:** the `dev` dependency group (`pytest`, `ruff`, `mypy`) to run the test suite. +- **Optional:** the `dev` dependency group (`pytest`, `ruff`, `mypy`) for automated tests, + and curl for manual verification. ## Run it +Run the commands below from the repository root. +`uv` prepares the environment and installs the dependencies on first use. + ```bash # From a context that is NOT sandboxed — binding a listen socket and # making unrestricted outbound is exactly this process's job. @@ -59,6 +65,10 @@ uv run --project tools/egress-gateway egress-gateway # 127.0.0.1:8899 uv run --project tools/egress-gateway egress-gateway --port 9000 ``` +The supported mode is the default allowlisting forward proxy. +Additional proxy.py arguments are passed through without filtering and must be supplied by a trusted operator. +Extra services, plugins, and replacement handlers are outside the allowlist guarantee. + proxy.py keeps runtime state under `$HOME/.proxy`; if HOME is not writable in your environment, point it somewhere writable for this process: @@ -101,13 +111,29 @@ exact host. Loopback (`localhost`, `127.0.0.1`, `::1`) is always allowed. ## Test +### Automated tests + +The automated tests cover the allowlist policy and CLI startup. + +```bash +uv run --directory tools/egress-gateway --group dev pytest +``` + +### Manual verification + +Start the gateway as shown above, then run these commands in another terminal with curl available. +The examples use the default proxy address and allowlist. +The allowed request requires network access to PyPI. + ```bash -uv run --project tools/egress-gateway --group dev pytest +# Allowed destination: expect a final HTTP 200 response from PyPI. +curl --noproxy "" --proxy http://127.0.0.1:8899 --max-time 10 --head https://pypi.org/ + +# Denied destination: expect HTTP 403. +curl --noproxy "" --proxy http://127.0.0.1:8899 --max-time 5 --include http://example.com/ ``` -The allowlist policy (`host_allowed`) is a pure function and is unit-tested -directly; the proxy.py integration is intentionally not exercised in CI -(it needs to bind a port). +The denied request returns HTTP 403 with `egress-gateway: host not on allowlist` in the response body. ## Caveat — host-level, not payload-level diff --git a/tools/egress-gateway/src/egress_gateway/__init__.py b/tools/egress-gateway/src/egress_gateway/__init__.py index bdf9c2eab..694ba86b6 100644 --- a/tools/egress-gateway/src/egress_gateway/__init__.py +++ b/tools/egress-gateway/src/egress_gateway/__init__.py @@ -16,6 +16,20 @@ # under the License. """Local egress-allowlist proxy for the apache-magpie framework.""" -from egress_gateway.allowlist import host_allowed +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from egress_gateway.allowlist import host_allowed __all__ = ["host_allowed"] + + +def __getattr__(name: str) -> Any: + if name == "host_allowed": + from egress_gateway.allowlist import host_allowed + + globals()[name] = host_allowed + return host_allowed + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/tools/egress-gateway/src/egress_gateway/cli.py b/tools/egress-gateway/src/egress_gateway/cli.py index 216827e48..1b7f4935f 100644 --- a/tools/egress-gateway/src/egress_gateway/cli.py +++ b/tools/egress-gateway/src/egress_gateway/cli.py @@ -54,8 +54,8 @@ def main(argv: Sequence[str] | None = None) -> int: # Imported lazily so `--help` works even if proxy.py is somehow absent. import proxy - proxy.main( - [ + with proxy.Proxy( + input_args=[ "--hostname", args.host, "--port", @@ -66,7 +66,8 @@ def main(argv: Sequence[str] | None = None) -> int: args.log_level, *passthrough, ] - ) + ): + proxy.sleep_loop() return 0 diff --git a/tools/egress-gateway/tests/test_cli.py b/tools/egress-gateway/tests/test_cli.py new file mode 100644 index 000000000..0c8367a22 --- /dev/null +++ b/tools/egress-gateway/tests/test_cli.py @@ -0,0 +1,172 @@ +# SPDX-License-Identifier: Apache-2.0 +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Exercise CLI startup against proxy.py without opening listening sockets.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import proxy +import pytest +from proxy.http.proxy import HttpProxyPlugin + +import egress_gateway +from egress_gateway import cli +from egress_gateway.allowlist import EgressAllowlistPlugin, host_allowed + + +@pytest.fixture +def isolated_proxy_args(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> list[str]: + user_proxy_dir = Path.home() / ".proxy" + real_makedirs = os.makedirs + + def guarded_makedirs(name: str | os.PathLike[str], mode: int = 0o777, exist_ok: bool = False) -> None: + path = Path(name).absolute() + if path == user_proxy_dir or user_proxy_dir in path.parents: + raise AssertionError("CLI tests must not create directories in the user's .proxy directory") + real_makedirs(name, mode=mode, exist_ok=exist_ok) + + monkeypatch.setattr(os, "makedirs", guarded_makedirs) + return ["--data-dir", str(tmp_path / "data"), "--cache-dir", str(tmp_path / "cache")] + + +@pytest.fixture +def lifecycle(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, proxy.Proxy]]: + events: list[tuple[str, proxy.Proxy]] = [] + + def wait() -> None: + events.append(("wait", events[0][1])) + + monkeypatch.setattr(proxy.Proxy, "setup", lambda gateway: events.append(("setup", gateway))) + monkeypatch.setattr(proxy.Proxy, "shutdown", lambda gateway: events.append(("shutdown", gateway))) + monkeypatch.setattr(proxy, "sleep_loop", wait) + return events + + +def test_main_starts_proxy_with_defaults( + isolated_proxy_args: list[str], lifecycle: list[tuple[str, proxy.Proxy]] +) -> None: + assert cli.main(isolated_proxy_args) == 0 + + gateway = lifecycle[0][1] + assert lifecycle == [("setup", gateway), ("wait", gateway), ("shutdown", gateway)] + assert str(gateway.flags.hostname) == "127.0.0.1" + assert gateway.flags.port == 8899 + assert gateway.flags.log_level == "INFO" + assert gateway.flags.plugins[b"HttpProxyBasePlugin"] == [EgressAllowlistPlugin] + assert gateway.flags.plugins[b"HttpProtocolHandlerPlugin"] == [HttpProxyPlugin] + assert gateway.flags.plugins[b"HttpWebServerBasePlugin"] == [] + assert gateway.flags.plugins[b"ReverseProxyBasePlugin"] == [] + + +@pytest.mark.parametrize("explicit_argv", [True, False]) +def test_main_forwards_custom_and_passthrough_arguments( + explicit_argv: bool, + tmp_path: Path, + isolated_proxy_args: list[str], + monkeypatch: pytest.MonkeyPatch, + lifecycle: list[tuple[str, proxy.Proxy]], +) -> None: + argv = [ + "--host", + "127.0.0.2", + "--port", + "9001", + "--log-level", + "DEBUG", + "--num-workers", + "1", + "--num-acceptors", + "2", + *isolated_proxy_args, + ] + process_argv = ["caller", "--port", "1"] if explicit_argv else ["egress-gateway", *argv] + monkeypatch.setattr(sys, "argv", process_argv) + + assert cli.main(argv if explicit_argv else None) == 0 + + gateway = lifecycle[0][1] + assert str(gateway.flags.hostname) == "127.0.0.2" + assert gateway.flags.port == 9001 + assert gateway.flags.log_level == "DEBUG" + assert gateway.flags.num_workers == 1 + assert gateway.flags.num_acceptors == 2 + assert gateway.flags.proxy_py_data_dir == str(tmp_path / "data") + assert gateway.flags.cache_dir == str(tmp_path / "cache") + assert gateway.flags.plugins[b"HttpProxyBasePlugin"] == [EgressAllowlistPlugin] + assert sys.argv is process_argv + + +@pytest.mark.parametrize( + "error", + [ + pytest.param(RuntimeError("wait failed"), id="runtime-error"), + pytest.param(SystemExit(0), id="signal-exit"), + pytest.param(KeyboardInterrupt(), id="keyboard-interrupt"), + ], +) +def test_main_shuts_down_when_waiting_raises( + error: BaseException, + isolated_proxy_args: list[str], + monkeypatch: pytest.MonkeyPatch, + lifecycle: list[tuple[str, proxy.Proxy]], +) -> None: + def fail_wait() -> None: + lifecycle.append(("wait", lifecycle[0][1])) + raise error + + monkeypatch.setattr(proxy, "sleep_loop", fail_wait) + + with pytest.raises(type(error)) as exc: + cli.main(isolated_proxy_args) + + assert exc.value is error + if isinstance(error, SystemExit): + assert error.code == 0 + + gateway = lifecycle[0][1] + assert lifecycle == [("setup", gateway), ("wait", gateway), ("shutdown", gateway)] + + +def test_help_does_not_import_proxy() -> None: + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys\nsys.modules['proxy'] = None\n" + "from egress_gateway.cli import main\nmain(['--help'])\n", + ], + capture_output=True, + text=True, + timeout=10, + ) + + assert result.returncode == 0, result.stderr + assert "--port" in result.stdout + + +def test_package_preserves_host_allowed_export() -> None: + assert egress_gateway.__all__ == ["host_allowed"] + assert egress_gateway.host_allowed is host_allowed + namespace: dict[str, object] = {} + exec("from egress_gateway import *", namespace) + assert namespace["host_allowed"] is host_allowed + assert not hasattr(egress_gateway, "unknown_attribute") From 44ca53aa6e9610af5ed9bb5d707a996dd481ba15 Mon Sep 17 00:00:00 2001 From: FENG <223868679+MakerFlyFly@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:22:11 +0000 Subject: [PATCH 2/2] test(egress-gateway): clarify CLI test imports and script Address two CodeQL quality findings, py/import-and-import-from and py/implicit-string-concatenation-in-list, by importing the CLI submodule explicitly and making the help script concatenation explicit. Preserve the script argument, dependency-free help, and package export coverage. Generated-by: Codex (GPT-6) --- tools/egress-gateway/tests/test_cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/egress-gateway/tests/test_cli.py b/tools/egress-gateway/tests/test_cli.py index 0c8367a22..e65e798b8 100644 --- a/tools/egress-gateway/tests/test_cli.py +++ b/tools/egress-gateway/tests/test_cli.py @@ -29,7 +29,7 @@ from proxy.http.proxy import HttpProxyPlugin import egress_gateway -from egress_gateway import cli +import egress_gateway.cli as cli from egress_gateway.allowlist import EgressAllowlistPlugin, host_allowed @@ -152,7 +152,7 @@ def test_help_does_not_import_proxy() -> None: sys.executable, "-c", "import sys\nsys.modules['proxy'] = None\n" - "from egress_gateway.cli import main\nmain(['--help'])\n", + + "from egress_gateway.cli import main\nmain(['--help'])\n", ], capture_output=True, text=True,