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
33 changes: 33 additions & 0 deletions .github/workflows/chrome-egress.yml
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions images/base/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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) ──────────
Expand Down
9 changes: 9 additions & 0 deletions images/base/scripts/zeish-chrome-cdp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" \
Expand All @@ -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))
Expand Down
11 changes: 11 additions & 0 deletions images/base/scripts/zeish-chrome-egress.sh
Original file line number Diff line number Diff line change
@@ -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'
}
1 change: 1 addition & 0 deletions images/desktop-x11/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions images/desktop-x11/scripts/zeish-chrome
Original file line number Diff line number Diff line change
Expand Up @@ -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 ".<screen>" suffix -> bare display number
DNUM="${DISPLAY_NAME#:}"
Expand Down Expand Up @@ -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[@]}"
}

Expand Down
114 changes: 114 additions & 0 deletions tests/test_chrome_egress.py
Original file line number Diff line number Diff line change
@@ -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()
Loading