fix(macos): reach the egress proxy by address in nested containers
podman's containers.conf `hosts_file` turns out to apply only to native `podman run`; the Docker-compatible API ignores it, and the agent types `docker`. (`base_hosts_file`, used before this, is a no-op in both paths on podman 5.4.2.) So no config key can put the gateway name in a nested container's /etc/hosts. The name is therefore resolved in the bottle, where /etc/hosts does carry it, and the address goes into the proxy URL handed to nested containers. NO_PROXY keeps the name, since that is matched against what a client asks for. Verified on the macOS host: with the gateway reachable, a nested container gets 200 from an allowlisted host and 403 from one that is not — the egress boundary applies inside nested containers too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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" <<CONF
|
||||
[containers]
|
||||
cgroups="disabled"
|
||||
base_hosts_file="/etc/hosts"
|
||||
volumes=["${CA_BUNDLE}:${CA_BUNDLE}:ro"]
|
||||
env=[
|
||||
"SSL_CERT_FILE=${CA_BUNDLE}",
|
||||
"CURL_CA_BUNDLE=${CA_BUNDLE}",
|
||||
"REQUESTS_CA_BUNDLE=${CA_BUNDLE}",
|
||||
"NODE_EXTRA_CA_CERTS=${CA_BUNDLE}",
|
||||
# hosts_file only takes effect for native `podman run` — the
|
||||
# Docker-compatible API ignores it, and the agent types
|
||||
# `docker`. Kept anyway because it costs nothing and makes
|
||||
# podman-native use behave; the compat path is covered by the
|
||||
# address-bearing proxy URL below.
|
||||
# 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.
|
||||
#
|
||||
# The proxy URL carries the bottle's identity token. podman already forwards
|
||||
# that same URL into every nested container from the agent's own environment,
|
||||
# so writing it to a 0600 file inside this disposable VM hands it to nobody
|
||||
# new. It is never echoed.
|
||||
CA_BUNDLE="$CA_BUNDLE" GATEWAY_NAME="$GATEWAY_NAME" GATEWAY_IP="$gateway_ip" \
|
||||
CONTAINERS_CONF="$config/containers.conf" python3 - <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
ca = os.environ["CA_BUNDLE"]
|
||||
name = os.environ["GATEWAY_NAME"]
|
||||
ip = os.environ["GATEWAY_IP"]
|
||||
|
||||
entries = [
|
||||
f"SSL_CERT_FILE={ca}",
|
||||
f"CURL_CA_BUNDLE={ca}",
|
||||
f"REQUESTS_CA_BUNDLE={ca}",
|
||||
f"NODE_EXTRA_CA_CERTS={ca}",
|
||||
]
|
||||
[engine]
|
||||
cgroup_manager="cgroupfs"
|
||||
events_logger="file"
|
||||
CONF
|
||||
# The gateway name resolves only through the bottle's /etc/hosts, which a
|
||||
# nested container does not inherit, so hand it the address instead.
|
||||
for var in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
|
||||
value = os.environ.get(var)
|
||||
if value:
|
||||
entries.append(f"{var}={value.replace(name, ip)}")
|
||||
# NO_PROXY keeps the name: it is matched against what a client asks for, and
|
||||
# code inside a nested container still says "bot-bottle-gateway".
|
||||
for var in ("NO_PROXY", "no_proxy"):
|
||||
value = os.environ.get(var)
|
||||
if value:
|
||||
entries.append(f"{var}={value}")
|
||||
|
||||
path = Path(os.environ["CONTAINERS_CONF"])
|
||||
path.write_text("\n".join([
|
||||
"[containers]",
|
||||
'cgroups="disabled"',
|
||||
'hosts_file="/etc/hosts"',
|
||||
f'volumes=["{ca}:{ca}:ro"]',
|
||||
"env=[",
|
||||
*[f' "{entry}",' for entry in entries],
|
||||
"]",
|
||||
"[engine]",
|
||||
'cgroup_manager="cgroupfs"',
|
||||
'events_logger="file"',
|
||||
"",
|
||||
]), encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
PY
|
||||
|
||||
# Registry pulls egress through the bottle's proxy like everything else. The
|
||||
# token-bearing proxy URL is already in the agent's environment; persisting it
|
||||
|
||||
@@ -165,23 +165,39 @@ class TestInitScript(unittest.TestCase):
|
||||
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_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):
|
||||
|
||||
Reference in New Issue
Block a user