From 1116f9298eb3e0f396e882efc8ce6391db887d68 Mon Sep 17 00:00:00 2001 From: Aaqa Ishtyaq Date: Sat, 10 Oct 2026 15:05:45 +0530 Subject: [PATCH] feat(browser): send managed chrome traffic through the egress proxy Share proxy arguments between desktop and headless browser launchers. Pass ZEISH_EGRESS_PROXY to Chrome, keep private guest routes direct, and disable QUIC to route public requests through the policy proxy. Ignore tenant NO_PROXY values when constructing the browser bypass list. Fail startup when a managed browser cannot load the shared helper. Document base-before-desktop release order and browser restart behavior. Add a PR test gate covering both launchers and the missing-helper case. Verified six focused tests on the host and in a Linux container, bash syntax for all modified scripts, and both Dockerfile build checks. A real Chromium CDP smoke test in a network-isolated container confirmed public HTTP and HTTPS proxy routing despite NO_PROXY=*, plus direct loopback and .internal requests to private fixture services. Signed-off-by: Aaqa Ishtyaq --- .github/workflows/chrome-egress.yml | 33 ++++++ README.md | 17 +++ images/base/Dockerfile | 1 + images/base/scripts/zeish-chrome-cdp | 9 ++ images/base/scripts/zeish-chrome-egress.sh | 11 ++ images/desktop-x11/README.md | 1 + images/desktop-x11/scripts/zeish-chrome | 11 ++ tests/test_chrome_egress.py | 114 +++++++++++++++++++++ 8 files changed, 197 insertions(+) create mode 100644 .github/workflows/chrome-egress.yml create mode 100644 images/base/scripts/zeish-chrome-egress.sh create mode 100644 tests/test_chrome_egress.py diff --git a/.github/workflows/chrome-egress.yml b/.github/workflows/chrome-egress.yml new file mode 100644 index 0000000..6752c89 --- /dev/null +++ b/.github/workflows/chrome-egress.yml @@ -0,0 +1,33 @@ +name: Chrome Egress + +on: + pull_request: + paths: + - '.github/workflows/chrome-egress.yml' + - 'images/base/Dockerfile' + - 'images/base/scripts/zeish-chrome*' + - 'images/desktop-x11/scripts/zeish-chrome' + - 'tests/test_chrome_egress.py' + push: + paths: + - '.github/workflows/chrome-egress.yml' + - 'images/base/Dockerfile' + - 'images/base/scripts/zeish-chrome*' + - 'images/desktop-x11/scripts/zeish-chrome' + - 'tests/test_chrome_egress.py' + +permissions: + contents: read + +jobs: + launcher-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check launcher syntax + run: | + bash -n images/base/scripts/zeish-chrome-cdp \ + images/base/scripts/zeish-chrome-egress.sh \ + images/desktop-x11/scripts/zeish-chrome + - name: Check managed browser egress arguments + run: python3 -m unittest discover -s tests -p test_chrome_egress.py -v diff --git a/README.md b/README.md index 7657cfc..7bc5e13 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,23 @@ Arin (`@arin/harness` `ensureZeishBrowserCdp`) prefers this helper, then falls back to the same binary paths. Edge templates should use the **desktop** image and expose port **9222** for preview/CDP tunnels. +Both `zeish-chrome-cdp` and the desktop `zeish-chrome` launcher use +`ZEISH_EGRESS_PROXY` as Chrome's `--proxy-server` when Compute supplies it. +Chrome does not use `HTTP_PROXY` by itself. Loopback and `.internal` names +bypass the public proxy so local guest services and private names use +direct guest routes, subject to the host's network policy. The launchers +disable QUIC when a proxy is configured; +public HTTP and HTTPS requests go through the policy proxy. Arbitrary +`NO_PROXY` entries are not copied into Chrome's bypass list. Compute's +network enforcement remains responsible for denying protected addresses +and direct connections that would bypass an internet policy. + +The shared proxy flag helper ships in `base`. Release `base` before +releasing `desktop-x11` with these launchers. A desktop using an older base +fails browser startup when `ZEISH_EGRESS_PROXY` is set and the helper is +missing. Changing the proxy environment requires restarting the browser; +policy changes at the existing proxy endpoint take effect on new requests. + ## Building locally Each image has a `Makefile` with standard targets. diff --git a/images/base/Dockerfile b/images/base/Dockerfile index 76e8193..97aa348 100644 --- a/images/base/Dockerfile +++ b/images/base/Dockerfile @@ -140,6 +140,7 @@ RUN ARCH=$(dpkg --print-architecture) \ # Shared by desktop + workstation (both FROM base). Starts Chrome with # --remote-debugging-port; see scripts/zeish-chrome-cdp. COPY scripts/zeish-chrome-cdp /usr/local/bin/zeish-chrome-cdp +COPY scripts/zeish-chrome-egress.sh /usr/local/lib/zeish/chrome-egress.sh RUN chmod +x /usr/local/bin/zeish-chrome-cdp # ── Python via pyenv (version-switchable, alongside system python3) ────────── diff --git a/images/base/scripts/zeish-chrome-cdp b/images/base/scripts/zeish-chrome-cdp index c304c63..d662ab3 100755 --- a/images/base/scripts/zeish-chrome-cdp +++ b/images/base/scripts/zeish-chrome-cdp @@ -13,6 +13,10 @@ set -u +if [ -n "${ZEISH_EGRESS_PROXY:-}" ]; then + source /usr/local/lib/zeish/chrome-egress.sh || exit 1 +fi + PORT="${1:-9222}" if ! command -v python3 >/dev/null 2>&1; then echo "NO_CHROME: python3 is required for the CDP relay" >&2 @@ -103,6 +107,10 @@ MAX_RESTARTS=5 # genuinely broken binary doesn't spin the VM's CPU forever. watchdog() { local BIN="$1" + local -a egress=() + if [ -n "${ZEISH_EGRESS_PROXY:-}" ]; then + mapfile -t egress < <(zeish_chrome_egress_flags) + fi local restarts=0 while [ "$restarts" -le "$MAX_RESTARTS" ]; do "$BIN" \ @@ -115,6 +123,7 @@ watchdog() { --remote-debugging-address=127.0.0.1 \ --remote-debugging-port="$LOCAL_PORT" \ --user-data-dir="$UD" \ + "${egress[@]}" \ about:blank >>"$LOG" 2>&1 local code=$? restarts=$((restarts + 1)) diff --git a/images/base/scripts/zeish-chrome-egress.sh b/images/base/scripts/zeish-chrome-egress.sh new file mode 100644 index 0000000..c670c24 --- /dev/null +++ b/images/base/scripts/zeish-chrome-egress.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +# Chrome ignores the conventional HTTP proxy environment. Both browser launchers +# use these flags so public requests traverse the machine's policy proxy. +zeish_chrome_egress_flags() { + [ -n "${ZEISH_EGRESS_PROXY:-}" ] || return 0 + printf '%s\n' \ + "--proxy-server=${ZEISH_EGRESS_PROXY}" \ + '--proxy-bypass-list=localhost;127.0.0.1;[::1];*.internal' \ + '--disable-quic' +} diff --git a/images/desktop-x11/README.md b/images/desktop-x11/README.md index 55fda5e..647c234 100644 --- a/images/desktop-x11/README.md +++ b/images/desktop-x11/README.md @@ -101,6 +101,7 @@ the same file before starting a fork's Xvfb. | `VNC_DEPTH` | `24` | Xvfb colour depth | | `VNC_PORT` | `5900` | loopback x11vnc port | | `PROXY_PORT` | `6080` | noVNC / websockify port | +| `ZEISH_EGRESS_PROXY` | unset | Compute-managed public egress proxy passed to Chrome's `--proxy-server`; loopback and `.internal` destinations bypass it | Port 6080 intentionally has no second websockify password or token. The security boundary is proxyd's authenticated DesktopSession route plus the diff --git a/images/desktop-x11/scripts/zeish-chrome b/images/desktop-x11/scripts/zeish-chrome index 3a9f60f..4384c5c 100755 --- a/images/desktop-x11/scripts/zeish-chrome +++ b/images/desktop-x11/scripts/zeish-chrome @@ -24,6 +24,12 @@ # its own browser. set -u +if [ -n "${ZEISH_EGRESS_PROXY:-}" ]; then + # A managed machine must not silently launch an unproxied browser when its + # base image predates the shared policy helper. + source /usr/local/lib/zeish/chrome-egress.sh || exit 1 +fi + DISPLAY_NAME="${DISPLAY:-:1}" # strip leading ':' and any "." suffix -> bare display number DNUM="${DISPLAY_NAME#:}" @@ -113,6 +119,11 @@ common_flags() { ) # Chrome refuses its sandbox as uid 0; sandboxd runs zeish-chrome as root. [ "$(id -u)" = "0" ] && f+=(--no-sandbox) + if [ -n "${ZEISH_EGRESS_PROXY:-}" ]; then + local -a egress + mapfile -t egress < <(zeish_chrome_egress_flags) + f+=("${egress[@]}") + fi printf '%s\n' "${f[@]}" } diff --git a/tests/test_chrome_egress.py b/tests/test_chrome_egress.py new file mode 100644 index 0000000..232f3f2 --- /dev/null +++ b/tests/test_chrome_egress.py @@ -0,0 +1,114 @@ +"""Browser egress arguments: public traffic is proxied, private traffic stays local.""" + +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +HELPER = ROOT / "images/base/scripts/zeish-chrome-egress.sh" + + +class ChromeEgressTests(unittest.TestCase): + def flags(self, proxy=None, no_proxy=None): + env = os.environ.copy() + env.pop("ZEISH_EGRESS_PROXY", None) + env.pop("NO_PROXY", None) + if proxy is not None: + env["ZEISH_EGRESS_PROXY"] = proxy + if no_proxy is not None: + env["NO_PROXY"] = no_proxy + return subprocess.check_output( + ["bash", "-c", 'source "$1"; zeish_chrome_egress_flags', "bash", str(HELPER)], + env=env, + text=True, + ).splitlines() + + def test_unmanaged_browser_does_not_acquire_a_proxy(self): + self.assertEqual([], self.flags()) + self.assertEqual([], self.flags("")) + + def test_managed_proxy_keeps_ipv6_endpoint_in_one_argument(self): + self.assertEqual( + [ + "--proxy-server=http://[fdaa::1]:3128", + "--proxy-bypass-list=localhost;127.0.0.1;[::1];*.internal", + "--disable-quic", + ], + self.flags("http://[fdaa::1]:3128"), + ) + + def test_tenant_no_proxy_does_not_bypass_public_policy(self): + self.assertEqual( + self.flags("http://[fdaa::1]:3128"), + self.flags("http://[fdaa::1]:3128", "*,blocked.example.com,10.0.0.0/8"), + ) + + def test_managed_launchers_fail_before_startup_without_helper(self): + with tempfile.TemporaryDirectory() as tmp: + absent_helper = str(Path(tmp) / "missing-helper.sh") + for relative in ( + "images/base/scripts/zeish-chrome-cdp", + "images/desktop-x11/scripts/zeish-chrome", + ): + with self.subTest(launcher=relative): + launcher = (ROOT / relative).read_text().replace( + "/usr/local/lib/zeish/chrome-egress.sh", absent_helper + ) + env = os.environ.copy() + env["ZEISH_EGRESS_PROXY"] = "http://127.0.0.1:3128" + result = subprocess.run( + ["bash", "-c", launcher], + env=env, + capture_output=True, + text=True, + timeout=5, + ) + self.assertEqual(1, result.returncode) + self.assertIn(absent_helper, result.stderr) + + def test_desktop_launcher_passes_policy_to_chrome(self): + launcher = (ROOT / "images/desktop-x11/scripts/zeish-chrome").read_text() + function = launcher.split("common_flags() {", 1)[1].split("\n# Bounded restart", 1)[0] + result = subprocess.check_output( + [ + "bash", "-c", + 'source "$1"; PROFILE_DIR=/tmp/profile; CDP_PORT=9223; ' + 'ZEISH_EGRESS_PROXY=http://127.0.0.1:3128; common_flags() {' + + function + '\ncommon_flags', + "bash", str(HELPER), + ], + text=True, + ).splitlines() + self.assertIn("--remote-debugging-address=127.0.0.1", result) + self.assertIn("--proxy-server=http://127.0.0.1:3128", result) + self.assertIn("--proxy-bypass-list=localhost;127.0.0.1;[::1];*.internal", result) + + def test_headless_launcher_passes_policy_to_chrome(self): + launcher = (ROOT / "images/base/scripts/zeish-chrome-cdp").read_text() + function = launcher.split("watchdog() {", 1)[1].split("\nstart_bin() {", 1)[0] + with tempfile.TemporaryDirectory() as tmp: + capture = Path(tmp) / "args" + subprocess.run( + [ + "bash", "-c", + 'source "$1"; CAPTURE="$2"; LOG=/dev/null; MAX_RESTARTS=0; ' + 'LOCAL_PORT=9222; UD=/tmp/profile; ' + 'ZEISH_EGRESS_PROXY=http://127.0.0.1:3128; ' + 'sleep() { :; }; fake_chrome() { printf "%s\\n" "$@" > "$CAPTURE"; }; ' + 'watchdog() {' + function + '\nwatchdog fake_chrome', + "bash", str(HELPER), str(capture), + ], + check=True, + ) + result = capture.read_text().splitlines() + self.assertIn("--headless=new", result) + self.assertIn("--remote-debugging-address=127.0.0.1", result) + self.assertIn("--proxy-server=http://127.0.0.1:3128", result) + self.assertIn("--proxy-bypass-list=localhost;127.0.0.1;[::1];*.internal", result) + + +if __name__ == "__main__": + unittest.main()