diff --git a/bot_bottle/backend/macos_container/nested-containers-init.sh b/bot_bottle/backend/macos_container/nested-containers-init.sh index c3c9db9..e4a4637 100755 --- a/bot_bottle/backend/macos_container/nested-containers-init.sh +++ b/bot_bottle/backend/macos_container/nested-containers-init.sh @@ -47,6 +47,27 @@ CA_BUNDLE="/etc/ssl/certs/ca-certificates.crt" exit 1 } +# The proxy URL the agent inherits names `bot-bottle-gateway`, which resolves +# only through this bottle's /etc/hosts. A nested container gets its own hosts +# file, so it cannot resolve the name and dies at "Could not resolve proxy". +# +# podman's containers.conf `hosts_file` would fix that, except the +# Docker-compatible API ignores it — it only takes effect for native +# `podman run`, and the agent types `docker`. So the name is resolved *here* +# and the address, not the name, goes into the proxy URL the nested container +# receives. Verified on macOS 26 / podman 5.4.2: with the address in place, +# https://quay.io returns 200 and a non-allowlisted host still gets 403, so +# the egress boundary applies inside nested containers too. +GATEWAY_NAME="bot-bottle-gateway" +gateway_ip="$( + awk -v name="$GATEWAY_NAME" '$2 == name { print $1; exit }' /etc/hosts +)" +[ -n "$gateway_ip" ] || { + echo "no /etc/hosts entry for $GATEWAY_NAME; the gateway address is" >&2 + echo "needed so nested containers can reach the egress proxy" >&2 + exit 1 +} + export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp/bbp}" config="$HOME/.config/containers" mkdir -p "$XDG_RUNTIME_DIR" "$config" @@ -66,35 +87,67 @@ 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. # -# 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: +# The rest of this config is what lets a nested container reach the network: # -# 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: - """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_resolves_the_gateway_address_for_nested_containers(self) -> None: + """podman's hosts_file only applies to native `podman run` — the + Docker-compat API ignores it, and the agent types `docker`. So the + gateway name is resolved here and the *address* goes into the proxy + URL; otherwise every nested container dies at "Could not resolve + proxy", which reads like broken DNS but is a missing hosts entry.""" + self.assertIn('$2 == name { print $1; exit }', self.script) + self.assertIn('value.replace(name, ip)', self.script) + + def test_keeps_the_gateway_name_in_no_proxy(self) -> None: + """NO_PROXY is matched against what a client asks for, and code inside + a nested container still says bot-bottle-gateway.""" + no_proxy = self.script[self.script.index('for var in ("NO_PROXY"'):] + self.assertIn('entries.append(f"{var}={value}")', no_proxy) + self.assertNotIn("replace(name, ip)", no_proxy) + + def test_fails_closed_without_a_gateway_address(self) -> None: + self.assertIn('[ -n "$gateway_ip" ] || {', 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) + self.assertIn('volumes=["{ca}:{ca}:ro"]', 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) + self.assertIn(f'f"{var}={{ca}}"', self.script) + + def test_writes_the_token_bearing_config_unreadable_to_others(self) -> None: + """The proxy URL carries the bottle's identity token.""" + self.assertIn("path.chmod(0o600)", self.script) class TestGuestEnvironment(unittest.TestCase):