From 82cf9bab5a781209a84530091992cabf8e6ca8b9 Mon Sep 17 00:00:00 2001 From: didericis Date: Tue, 21 Jul 2026 19:37:39 -0400 Subject: [PATCH] feat(macos): give nested containers working egress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers the last open question in #392: DNS from inside a nested container was not a phantom, but it was also not a DNS problem. Public resolution fails there by design — everything egresses through the proxy — and the actual breakage was two missing pieces: - The proxy URL names `bot-bottle-gateway`, which resolves only through the bottle's own /etc/hosts. Containers got their own hosts file, so they failed at "Could not resolve proxy", which reads like broken DNS. base_hosts_file propagates the bottle's entries. - The gateway TLS-intercepts, so a container that does not trust the bottle's CA bundle fails with "unable to get local issuer certificate". The bundle is now mounted read-only and the usual env vars point at it, which covers curl, wget, python, and node without distro-specific trust commands. Verified on the macOS host by reproducing each failure and confirming that these three conditions applied by hand produced HTTP 200 from inside a nested container. podman already forwards the proxy env, so that needed nothing. Co-Authored-By: Claude Opus 4.8 --- .../macos_container/nested-containers-init.sh | 33 ++++++++++++++++++- tests/unit/test_macos_nested_containers.py | 33 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/bot_bottle/backend/macos_container/nested-containers-init.sh b/bot_bottle/backend/macos_container/nested-containers-init.sh index 56daaf4..c3c9db9 100755 --- a/bot_bottle/backend/macos_container/nested-containers-init.sh +++ b/bot_bottle/backend/macos_container/nested-containers-init.sh @@ -39,6 +39,14 @@ done # Short by necessity, not by accident: conmon's attach socket lives under # this directory and must fit in a 108-byte sun_path. See nested_containers.py. +# Must stay in step with AGENT_CA_BUNDLE in bot_bottle/backend/util.py; a unit +# test pins the two together. +CA_BUNDLE="/etc/ssl/certs/ca-certificates.crt" +[ -r "$CA_BUNDLE" ] || { + echo "gateway CA bundle $CA_BUNDLE is missing or unreadable" >&2 + exit 1 +} + export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp/bbp}" config="$HOME/.config/containers" mkdir -p "$XDG_RUNTIME_DIR" "$config" @@ -57,9 +65,32 @@ CONF # No cgroup delegation reaches this guest, so asking podman to manage cgroups # fails; events_logger=file avoids the journald socket that is equally absent. -cat > "$config/containers.conf" <<'CONF' +# +# The rest of this block is what makes a nested container reach the network at +# all. podman already forwards the proxy env, but that alone is useless here: +# +# base_hosts_file the proxy URL names `bot-bottle-gateway`, which resolves +# only through the bottle's own /etc/hosts. Without this a +# container fails at "Could not resolve proxy", which reads +# like broken DNS but is a missing hosts entry — public DNS +# is *supposed* to fail, since everything egresses via the +# proxy. +# volumes/env the gateway TLS-intercepts, so a container that does not +# trust the bottle's CA bundle gets "unable to get local +# issuer certificate". Mounting the bundle read-only and +# pointing the usual env vars at it covers curl, wget, +# python, and node without distro-specific trust commands. +cat > "$config/containers.conf" < None: + self.script = ( + Path(nested_containers.__file__).with_name("nested-containers-init.sh") + .read_text(encoding="utf-8") + ) + + def test_ca_bundle_path_matches_the_backend_constant(self) -> None: + from bot_bottle.backend.util import AGENT_CA_BUNDLE + self.assertIn(f'CA_BUNDLE="{AGENT_CA_BUNDLE}"', self.script) + + def test_propagates_the_bottle_hosts_file_into_containers(self) -> None: + """The proxy URL names bot-bottle-gateway, which resolves only via the + bottle's /etc/hosts. Without this a nested container dies at "Could + not resolve proxy" — which reads as broken DNS but is a missing hosts + entry; public DNS is meant to fail.""" + self.assertIn('base_hosts_file="/etc/hosts"', self.script) + + def test_mounts_and_trusts_the_gateway_ca(self) -> None: + """The gateway TLS-intercepts, so without the bundle every HTTPS call + from a nested container fails with "unable to get local issuer + certificate".""" + self.assertIn("volumes=[", self.script) + for var in ( + "SSL_CERT_FILE", "CURL_CA_BUNDLE", "REQUESTS_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", + ): + self.assertIn(f'"{var}=${{CA_BUNDLE}}"', self.script) + + class TestGuestEnvironment(unittest.TestCase): def test_disabled_bottle_gets_no_docker_environment(self) -> None: self.assertEqual({}, nested_containers.guest_env(False))