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
38 changes: 32 additions & 6 deletions tools/egress-gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

<!-- END doctoc generated TOC please keep comment here to allow auto update -->
Expand Down Expand Up @@ -40,25 +42,33 @@ 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
makes unrestricted outbound by design — must run in a **non-sandboxed**
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.
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:

Expand Down Expand Up @@ -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

Expand Down
16 changes: 15 additions & 1 deletion tools/egress-gateway/src/egress_gateway/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
7 changes: 4 additions & 3 deletions tools/egress-gateway/src/egress_gateway/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -66,7 +66,8 @@ def main(argv: Sequence[str] | None = None) -> int:
args.log_level,
*passthrough,
]
)
):
proxy.sleep_loop()
return 0


Expand Down
172 changes: 172 additions & 0 deletions tools/egress-gateway/tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
import egress_gateway.cli as 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")
Loading