Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7644da4280 | |||
| 13e4af421d |
@@ -0,0 +1,360 @@
|
|||||||
|
# Apple Container networking spike
|
||||||
|
|
||||||
|
Issue: https://gitea.dideric.is/didericis/bot-bottle/issues/230
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Apple Container 1.0.0 on macOS 26 can support the core two-network
|
||||||
|
sidecar shape, but not as a drop-in Docker Compose clone.
|
||||||
|
|
||||||
|
The viable shape is:
|
||||||
|
|
||||||
|
- agent container on one `--internal` host-only network;
|
||||||
|
- sidecar bundle container on both the NAT egress network and the
|
||||||
|
host-only agent network;
|
||||||
|
- sidecar network flags ordered with the NAT network first, because
|
||||||
|
Apple Container chooses the first network as the default route;
|
||||||
|
- explicit DNS on the sidecar, because the tested NAT gateway routed
|
||||||
|
packets but did not resolve DNS;
|
||||||
|
- agent talks to sidecar by the sidecar's host-only-network IP, not by
|
||||||
|
container name or host-published loopback alias.
|
||||||
|
|
||||||
|
This is enough to unblock a cautious `macos-container` launch spike if
|
||||||
|
the backend records inspect-derived IPs and avoids depending on Docker
|
||||||
|
Compose-style aliases. It is not enough to reuse the Docker backend's
|
||||||
|
service-name assumptions unchanged.
|
||||||
|
|
||||||
|
## Local Environment
|
||||||
|
|
||||||
|
Tested on 2026-06-10:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ sw_vers
|
||||||
|
ProductName: macOS
|
||||||
|
ProductVersion: 26.5.1
|
||||||
|
BuildVersion: 25F80
|
||||||
|
|
||||||
|
$ uname -m
|
||||||
|
arm64
|
||||||
|
|
||||||
|
$ container --version
|
||||||
|
container CLI version 1.0.0 (build: release, commit: ee848e3)
|
||||||
|
|
||||||
|
$ container system version --format json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"appName": "container",
|
||||||
|
"buildType": "release",
|
||||||
|
"commit": "ee848e3ebfd7c73b04dd419683be54fb450b8779",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appName": "container-apiserver",
|
||||||
|
"buildType": "release",
|
||||||
|
"commit": "ee848e3ebfd7c73b04dd419683be54fb450b8779",
|
||||||
|
"version": "container-apiserver version 1.0.0 (build: release, commit: ee848e3)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
$ container system status --format json
|
||||||
|
{
|
||||||
|
"apiServerAppName": "container-apiserver",
|
||||||
|
"apiServerBuild": "release",
|
||||||
|
"apiServerCommit": "ee848e3ebfd7c73b04dd419683be54fb450b8779",
|
||||||
|
"apiServerVersion": "container-apiserver version 1.0.0 (build: release, commit: ee848e3)",
|
||||||
|
"appRoot": "/Users/didericis/Library/Application Support/com.apple.container/",
|
||||||
|
"installRoot": "/usr/local/",
|
||||||
|
"status": "running"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Apple Container was installed from the official signed 1.0.0 GitHub
|
||||||
|
release package, `container-1.0.0-installer-signed.pkg`. The package was
|
||||||
|
signed by `Developer ID Installer: Apple Inc. - Containerization
|
||||||
|
(UPBK2H6LZM)` and notarized by Apple.
|
||||||
|
|
||||||
|
## Commands Run
|
||||||
|
|
||||||
|
Create the networks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
container network create bb-spike-230-agent \
|
||||||
|
--internal \
|
||||||
|
--label bot-bottle.spike=apple-container-networking
|
||||||
|
|
||||||
|
container network create bb-spike-230-egress \
|
||||||
|
--label bot-bottle.spike=apple-container-networking
|
||||||
|
```
|
||||||
|
|
||||||
|
`container network inspect bb-spike-230-agent bb-spike-230-egress`
|
||||||
|
showed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"configuration": {
|
||||||
|
"labels": {"bot-bottle.spike": "apple-container-networking"},
|
||||||
|
"mode": "hostOnly",
|
||||||
|
"name": "bb-spike-230-agent",
|
||||||
|
"plugin": "container-network-vmnet"
|
||||||
|
},
|
||||||
|
"id": "bb-spike-230-agent",
|
||||||
|
"status": {
|
||||||
|
"ipv4Gateway": "192.168.128.1",
|
||||||
|
"ipv4Subnet": "192.168.128.0/24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"configuration": {
|
||||||
|
"labels": {"bot-bottle.spike": "apple-container-networking"},
|
||||||
|
"mode": "nat",
|
||||||
|
"name": "bb-spike-230-egress",
|
||||||
|
"plugin": "container-network-vmnet"
|
||||||
|
},
|
||||||
|
"id": "bb-spike-230-egress",
|
||||||
|
"status": {
|
||||||
|
"ipv4Gateway": "192.168.66.1",
|
||||||
|
"ipv4Subnet": "192.168.66.0/24"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Repeated `--network` flags are accepted. With the agent network first,
|
||||||
|
the sidecar got two interfaces but the default route pointed at the
|
||||||
|
host-only gateway, so egress failed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
container run --name bb-spike-230-sidecar \
|
||||||
|
--label bot-bottle.spike=apple-container-networking \
|
||||||
|
--network bb-spike-230-agent \
|
||||||
|
--network bb-spike-230-egress \
|
||||||
|
--detach --rm docker.io/python:alpine \
|
||||||
|
sh -c 'mkdir -p /srv && printf ok >/srv/index.html && cd /srv && python3 -m http.server 80 --bind 0.0.0.0'
|
||||||
|
|
||||||
|
container exec bb-spike-230-sidecar sh -c 'ip route && cat /etc/resolv.conf'
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
|
||||||
|
```console
|
||||||
|
default via 192.168.128.1 dev eth0
|
||||||
|
192.168.66.0/24 dev eth1 scope link src 192.168.66.3
|
||||||
|
192.168.128.0/24 dev eth0 scope link src 192.168.128.3
|
||||||
|
nameserver 192.168.128.1
|
||||||
|
```
|
||||||
|
|
||||||
|
With the NAT network first and explicit DNS, the sidecar can egress:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
container run --name bb-spike-230-sidecar \
|
||||||
|
--label bot-bottle.spike=apple-container-networking \
|
||||||
|
--network bb-spike-230-egress \
|
||||||
|
--network bb-spike-230-agent \
|
||||||
|
--dns 1.1.1.1 \
|
||||||
|
--detach docker.io/python:alpine \
|
||||||
|
sh -c 'mkdir -p /srv && printf ok >/srv/index.html && cd /srv && python3 -m http.server 80 --bind 0.0.0.0'
|
||||||
|
|
||||||
|
container exec bb-spike-230-sidecar sh -c 'ip route; cat /etc/resolv.conf; wget -T 8 -O- https://example.com'
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
|
||||||
|
```console
|
||||||
|
default via 192.168.66.1 dev eth0
|
||||||
|
192.168.66.0/24 dev eth0 scope link src 192.168.66.5
|
||||||
|
192.168.128.0/24 dev eth1 scope link src 192.168.128.7
|
||||||
|
nameserver 1.1.1.1
|
||||||
|
Connecting to example.com (172.66.147.243:443)
|
||||||
|
... 100%
|
||||||
|
```
|
||||||
|
|
||||||
|
Start an agent only on the host-only network:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
container run --name bb-spike-230-agent \
|
||||||
|
--label bot-bottle.spike=apple-container-networking \
|
||||||
|
--network bb-spike-230-agent \
|
||||||
|
--detach docker.io/alpine:latest sleep 600
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent network probes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
container exec bb-spike-230-agent sh -c '
|
||||||
|
ip route
|
||||||
|
cat /etc/resolv.conf
|
||||||
|
wget -T 5 -O- http://192.168.128.7
|
||||||
|
wget -T 5 -O- http://bb-spike-230-sidecar || true
|
||||||
|
ping -c 2 1.1.1.1 || true
|
||||||
|
wget -T 5 -O- https://example.com || true
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
|
||||||
|
```console
|
||||||
|
default via 192.168.128.1 dev eth0
|
||||||
|
192.168.128.0/24 dev eth0 scope link src 192.168.128.8
|
||||||
|
nameserver 192.168.128.1
|
||||||
|
Connecting to 192.168.128.7 (192.168.128.7:80)
|
||||||
|
ok
|
||||||
|
wget: bad address 'bb-spike-230-sidecar'
|
||||||
|
2 packets transmitted, 0 packets received, 100% packet loss
|
||||||
|
wget: bad address 'example.com'
|
||||||
|
```
|
||||||
|
|
||||||
|
Host-published loopback aliases work and are constrained to the bound
|
||||||
|
alias on the host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
container run --name bb-spike-230-sidecar-alias \
|
||||||
|
--label bot-bottle.spike=apple-container-networking \
|
||||||
|
--network bb-spike-230-egress \
|
||||||
|
--network bb-spike-230-agent \
|
||||||
|
--dns 1.1.1.1 \
|
||||||
|
--publish 127.0.0.31:18080:80 \
|
||||||
|
--detach docker.io/python:alpine \
|
||||||
|
sh -c 'mkdir -p /srv && printf ok >/srv/index.html && cd /srv && python3 -m http.server 80 --bind 0.0.0.0'
|
||||||
|
|
||||||
|
curl -fsS --max-time 5 http://127.0.0.31:18080
|
||||||
|
curl -fsS --max-time 5 http://127.0.0.1:18080
|
||||||
|
lsof -nP -iTCP:18080 -sTCP:LISTEN
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ curl -fsS --max-time 5 http://127.0.0.31:18080
|
||||||
|
ok
|
||||||
|
|
||||||
|
$ curl -fsS --max-time 5 http://127.0.0.1:18080
|
||||||
|
curl: (7) Failed to connect to 127.0.0.1 port 18080 after 0 ms: Couldn't connect to server
|
||||||
|
|
||||||
|
$ lsof -nP -iTCP:18080 -sTCP:LISTEN
|
||||||
|
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
||||||
|
container 17908 didericis 25u IPv4 ... 0t0 TCP 127.0.0.31:18080 (LISTEN)
|
||||||
|
```
|
||||||
|
|
||||||
|
The guest cannot reach that host loopback-published listener through
|
||||||
|
the host-only gateway or through its own loopback address:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
container exec bb-spike-230-agent sh -c '
|
||||||
|
wget -T 5 -O- http://192.168.128.10
|
||||||
|
wget -T 5 -O- http://192.168.128.1:18080 || true
|
||||||
|
wget -T 5 -O- http://127.0.0.31:18080 || true
|
||||||
|
wget -T 5 -O- http://bb-spike-230-sidecar-alias || true
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
|
||||||
|
```console
|
||||||
|
Connecting to 192.168.128.10 (192.168.128.10:80)
|
||||||
|
ok
|
||||||
|
Connecting to 192.168.128.1:18080 (192.168.128.1:18080)
|
||||||
|
wget: can't connect to remote host (192.168.128.1): Connection refused
|
||||||
|
Connecting to 127.0.0.31:18080 (127.0.0.31:18080)
|
||||||
|
wget: can't connect to remote host (127.0.0.31): Connection refused
|
||||||
|
wget: bad address 'bb-spike-230-sidecar-alias'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Answers
|
||||||
|
|
||||||
|
### 1. Does `container network create --internal` prevent outbound internet access?
|
||||||
|
|
||||||
|
Yes in this run. `--internal` produced a `hostOnly` network. An
|
||||||
|
internal-only agent had a default route to the host-only gateway, but
|
||||||
|
could not ping `1.1.1.1` and could not resolve or fetch
|
||||||
|
`https://example.com`.
|
||||||
|
|
||||||
|
### 2. Can `container run` attach one container to multiple networks?
|
||||||
|
|
||||||
|
Yes. Repeated `--network` flags produced multiple interfaces and the
|
||||||
|
inspect JSON preserved both network attachments.
|
||||||
|
|
||||||
|
Important caveat: network order matters. The first network became
|
||||||
|
`eth0`, supplied the default route, and supplied `/etc/resolv.conf`.
|
||||||
|
For a sidecar that needs internet egress, put the NAT network first and
|
||||||
|
the internal agent network second.
|
||||||
|
|
||||||
|
### 3. Can the sidecar bundle sit on both an internal agent network and an egress-capable network?
|
||||||
|
|
||||||
|
Yes. The sidecar had a NAT interface and a host-only interface. With the
|
||||||
|
NAT network first and explicit DNS, it could fetch `https://example.com`
|
||||||
|
while the agent on only the host-only network could not.
|
||||||
|
|
||||||
|
### 4. Can Apple Container provide stable network aliases or service discovery equivalent to Docker Compose aliases?
|
||||||
|
|
||||||
|
Not by default in this run. The agent could not resolve
|
||||||
|
`bb-spike-230-sidecar` or `bb-spike-230-sidecar-alias`, even though
|
||||||
|
those were the container names and hostnames in inspect output. The
|
||||||
|
agent could reach the sidecar by the sidecar's host-only-network IP.
|
||||||
|
|
||||||
|
The backend should not assume Docker Compose-style aliases. It should
|
||||||
|
read the sidecar's host-only IP from `container inspect` and inject
|
||||||
|
that concrete endpoint into the agent environment/config, or run a
|
||||||
|
small internal DNS/hosts-file setup as an explicit backend feature.
|
||||||
|
|
||||||
|
### 5. Can a published sidecar port bound to a per-bottle loopback alias be reached from another Apple Container guest and constrained to that alias?
|
||||||
|
|
||||||
|
Host-side alias binding works and is constrained on the host:
|
||||||
|
`127.0.0.31:18080` reached the sidecar, while `127.0.0.1:18080` failed.
|
||||||
|
|
||||||
|
Guest-to-host-published-loopback did not work. From the agent,
|
||||||
|
`192.168.128.1:18080` and `127.0.0.31:18080` both failed. For
|
||||||
|
agent-to-sidecar traffic, use the sidecar's internal network IP rather
|
||||||
|
than a host-published loopback alias.
|
||||||
|
|
||||||
|
### 6. What structured output is available for robust enumeration and cleanup?
|
||||||
|
|
||||||
|
Confirmed structured output:
|
||||||
|
|
||||||
|
- `container list --all --format json`
|
||||||
|
- `container inspect <container...>` as JSON
|
||||||
|
- `container image inspect <image...>` as JSON
|
||||||
|
- `container network list --format json`
|
||||||
|
- `container network inspect <network...>` as JSON
|
||||||
|
- `container system status --format json`
|
||||||
|
- `container system version --format json`
|
||||||
|
|
||||||
|
Useful fields observed:
|
||||||
|
|
||||||
|
- containers: `id`, `configuration.labels`,
|
||||||
|
`configuration.networks`, `configuration.publishedPorts`,
|
||||||
|
`status.state`, `status.networks[].network`,
|
||||||
|
`status.networks[].ipv4Address`, `status.networks[].ipv4Gateway`;
|
||||||
|
- networks: `id`, `configuration.name`, `configuration.labels`,
|
||||||
|
`configuration.mode`, `status.ipv4Gateway`, `status.ipv4Subnet`;
|
||||||
|
- images: `id`, `configuration.name`, `configuration.descriptor`,
|
||||||
|
`variants[].platform`, `variants[].size`.
|
||||||
|
|
||||||
|
### 7. Are labels supported on containers and networks enough to replace prefix-only discovery?
|
||||||
|
|
||||||
|
Labels are present in container and network inspect/list JSON, so they
|
||||||
|
are sufficient as metadata if the backend lists resources and filters
|
||||||
|
client-side. I did not find or validate a server-side label filter for
|
||||||
|
`container list` or `container network list`.
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Proceed with a narrow `macos-container` launch prototype, but encode
|
||||||
|
the Apple Container-specific constraints directly:
|
||||||
|
|
||||||
|
- create one host-only agent network and one NAT egress network per
|
||||||
|
bottle;
|
||||||
|
- start the sidecar bundle with `--network <egress>` before
|
||||||
|
`--network <agent>`;
|
||||||
|
- set sidecar DNS explicitly, ideally from the bottle/host policy
|
||||||
|
rather than hardcoding a public resolver;
|
||||||
|
- start the agent only on the host-only network;
|
||||||
|
- discover the sidecar's host-only IP from `container inspect` and pass
|
||||||
|
concrete URLs to the agent;
|
||||||
|
- use host loopback publishing only for host-to-sidecar access, not
|
||||||
|
guest-to-sidecar access;
|
||||||
|
- enumerate and clean up by labels plus name prefixes until/unless the
|
||||||
|
CLI adds label filters.
|
||||||
|
|
||||||
|
Do not implement the backend as a direct clone of Docker Compose
|
||||||
|
service aliases. That assumption failed in this run.
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
# Apple Container transparent egress spike
|
||||||
|
|
||||||
|
Issue: https://gitea.dideric.is/didericis/bot-bottle/issues/230#issuecomment-1994
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Transparent egress is mechanically possible on Apple Container 1.0.0,
|
||||||
|
but it is not a free property of the platform and it is not a drop-in
|
||||||
|
replacement for `HTTP_PROXY` yet.
|
||||||
|
|
||||||
|
The spike proved two separate things:
|
||||||
|
|
||||||
|
- Plain routing/NAT works if the sidecar has `CAP_NET_ADMIN`, IP
|
||||||
|
forwarding, and masquerade rules, and if the agent default route is
|
||||||
|
changed to the sidecar's host-only-network IP.
|
||||||
|
- Transparent mitmproxy interception works if the sidecar redirects
|
||||||
|
agent-facing TCP 80/443 traffic to `mitmdump --mode transparent`.
|
||||||
|
Direct HTTP was logged by mitmproxy. Direct HTTPS reached mitmproxy;
|
||||||
|
it failed with normal certificate verification until the client
|
||||||
|
skipped verification, which is consistent with bot-bottle's existing
|
||||||
|
requirement that agents trust the sidecar CA.
|
||||||
|
- Running DNS on the sidecar and pointing the agent at the sidecar's
|
||||||
|
host-only IP also works. This is cleaner than relying on forwarded
|
||||||
|
UDP DNS to a public resolver and gives the backend a natural place to
|
||||||
|
enforce or observe DNS policy.
|
||||||
|
|
||||||
|
The hard blocker is agent routing. Apple Container 1.0.0 exposes no
|
||||||
|
documented `--network` gateway option. An ordinary agent container
|
||||||
|
cannot replace its default route:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ container exec bb-spike-230t-agent sh -c \
|
||||||
|
'ip route replace default via 192.168.128.2 dev eth0; ip route'
|
||||||
|
default via 192.168.128.1 dev eth0
|
||||||
|
192.168.128.0/24 dev eth0 scope link src 192.168.128.3
|
||||||
|
ip: RTNETLINK answers: Operation not permitted
|
||||||
|
```
|
||||||
|
|
||||||
|
The successful route-through-sidecar tests used `--cap-add
|
||||||
|
CAP_NET_ADMIN` on the agent so the route could be changed after start.
|
||||||
|
That is not an acceptable final design by itself: it expands the
|
||||||
|
agent's kernel-facing privilege and lets the agent mutate its own
|
||||||
|
network namespace. A production design needs either a backend-owned
|
||||||
|
init/shim that sets the route then drops privilege in a way the agent
|
||||||
|
cannot regain, a platform-supported gateway option, or a different
|
||||||
|
network attachment layer.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
Tested on 2026-06-10:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ sw_vers
|
||||||
|
ProductName: macOS
|
||||||
|
ProductVersion: 26.5.1
|
||||||
|
BuildVersion: 25F80
|
||||||
|
|
||||||
|
$ uname -m
|
||||||
|
arm64
|
||||||
|
|
||||||
|
$ container --version
|
||||||
|
container CLI version 1.0.0 (build: release, commit: ee848e3)
|
||||||
|
```
|
||||||
|
|
||||||
|
Apple Container system status:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiServerAppName": "container-apiserver",
|
||||||
|
"apiServerBuild": "release",
|
||||||
|
"apiServerCommit": "ee848e3ebfd7c73b04dd419683be54fb450b8779",
|
||||||
|
"apiServerVersion": "container-apiserver version 1.0.0 (build: release, commit: ee848e3)",
|
||||||
|
"appRoot": "/Users/didericis/Library/Application Support/com.apple.container/",
|
||||||
|
"installRoot": "/usr/local/",
|
||||||
|
"status": "running"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Baseline
|
||||||
|
|
||||||
|
Networks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
container network create bb-spike-230t-agent \
|
||||||
|
--internal \
|
||||||
|
--label bot-bottle.spike=transparent-egress
|
||||||
|
|
||||||
|
container network create bb-spike-230t-egress \
|
||||||
|
--label bot-bottle.spike=transparent-egress
|
||||||
|
```
|
||||||
|
|
||||||
|
Sidecar, dual-homed with NAT first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
container run --name bb-spike-230t-sidecar \
|
||||||
|
--label bot-bottle.spike=transparent-egress \
|
||||||
|
--network bb-spike-230t-egress \
|
||||||
|
--network bb-spike-230t-agent \
|
||||||
|
--dns 1.1.1.1 \
|
||||||
|
--detach docker.io/alpine:latest sleep 1800
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent, host-only network:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
container run --name bb-spike-230t-agent \
|
||||||
|
--label bot-bottle.spike=transparent-egress \
|
||||||
|
--network bb-spike-230t-agent \
|
||||||
|
--detach docker.io/alpine:latest sleep 1800
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed sidecar addresses:
|
||||||
|
|
||||||
|
```console
|
||||||
|
eth0 192.168.66.2/24 # NAT egress network
|
||||||
|
eth1 192.168.128.2/24 # host-only agent network
|
||||||
|
default via 192.168.66.1 dev eth0
|
||||||
|
nameserver 1.1.1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed agent baseline:
|
||||||
|
|
||||||
|
```console
|
||||||
|
eth0 192.168.128.3/24
|
||||||
|
default via 192.168.128.1 dev eth0
|
||||||
|
nameserver 192.168.128.1
|
||||||
|
wget: bad address 'pypi.org'
|
||||||
|
```
|
||||||
|
|
||||||
|
That confirms the previous spike's baseline: sidecar can egress, agent
|
||||||
|
cannot egress directly.
|
||||||
|
|
||||||
|
## Plain NAT Test
|
||||||
|
|
||||||
|
Relaunch sidecar and agent with `CAP_NET_ADMIN`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
container run --name bb-spike-230t-sidecar \
|
||||||
|
--label bot-bottle.spike=transparent-egress \
|
||||||
|
--network bb-spike-230t-egress \
|
||||||
|
--network bb-spike-230t-agent \
|
||||||
|
--dns 1.1.1.1 \
|
||||||
|
--cap-add CAP_NET_ADMIN \
|
||||||
|
--detach docker.io/alpine:latest sleep 1800
|
||||||
|
|
||||||
|
container run --name bb-spike-230t-agent \
|
||||||
|
--label bot-bottle.spike=transparent-egress \
|
||||||
|
--network bb-spike-230t-agent \
|
||||||
|
--cap-add CAP_NET_ADMIN \
|
||||||
|
--detach docker.io/alpine:latest sleep 1800
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure sidecar forwarding:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
container exec bb-spike-230t-sidecar sh -c '
|
||||||
|
apk add --no-cache iptables iproute2
|
||||||
|
sysctl -w net.ipv4.ip_forward=1
|
||||||
|
iptables -t nat -A POSTROUTING -s 192.168.128.0/24 -o eth0 -j MASQUERADE
|
||||||
|
iptables -A FORWARD -i eth1 -o eth0 -j ACCEPT
|
||||||
|
iptables -A FORWARD -i eth0 -o eth1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Point the agent at the sidecar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
container exec bb-spike-230t-agent sh -c '
|
||||||
|
ip route replace default via 192.168.128.4 dev eth0
|
||||||
|
printf "nameserver 1.1.1.1\n" > /etc/resolv.conf
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Normal direct PyPI fetch from the agent, with no proxy variables set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
container exec bb-spike-230t-agent sh -c '
|
||||||
|
for v in HTTP_PROXY HTTPS_PROXY http_proxy https_proxy ALL_PROXY all_proxy; do
|
||||||
|
if [ -n "$(printenv "$v")" ]; then echo "$v=SET"; fi
|
||||||
|
done
|
||||||
|
wget -T 10 -O- https://pypi.org/simple/pip/ | head -c 120
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
|
||||||
|
```console
|
||||||
|
Connecting to pypi.org (151.101.0.223:443)
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta name="pypi:repository-version" content="1.4">
|
||||||
|
```
|
||||||
|
|
||||||
|
Sidecar NAT counters increased:
|
||||||
|
|
||||||
|
```console
|
||||||
|
POSTROUTING MASQUERADE 3 packets / 168 bytes
|
||||||
|
FORWARD eth1 -> eth0 22 packets / 2806 bytes
|
||||||
|
FORWARD eth0 -> eth1 29 packets / 54781 bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
Verdict: plain transparent routing through the sidecar works, but this
|
||||||
|
is only NAT. It does not apply bot-bottle's existing route allowlist,
|
||||||
|
authorization stripping/injection, or DLP logic.
|
||||||
|
|
||||||
|
## Transparent Mitmproxy Test
|
||||||
|
|
||||||
|
The current sidecar launcher uses explicit proxy mode:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
MODE="--mode regular@9099"
|
||||||
|
exec mitmdump $CONFDIR_FLAG $MODE $LISTEN_HOST_FLAG $TRUST_FLAG -s /app/egress_addon.py
|
||||||
|
```
|
||||||
|
|
||||||
|
So transparent egress needs a launcher mode change plus iptables
|
||||||
|
redirects.
|
||||||
|
|
||||||
|
Run a test mitmproxy container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
container run --name bb-spike-230t-mitm \
|
||||||
|
--label bot-bottle.spike=transparent-egress \
|
||||||
|
--network bb-spike-230t-egress \
|
||||||
|
--network bb-spike-230t-agent \
|
||||||
|
--dns 1.1.1.1 \
|
||||||
|
--cap-add CAP_NET_ADMIN \
|
||||||
|
--detach mitmproxy/mitmproxy:11.1.3 \
|
||||||
|
sh -c 'apt-get update >/tmp/apt.log &&
|
||||||
|
apt-get install -y --no-install-recommends iptables iproute2 >>/tmp/apt.log &&
|
||||||
|
echo 1 > /proc/sys/net/ipv4/ip_forward &&
|
||||||
|
iptables -t nat -A PREROUTING -i eth1 -p tcp --dport 80 -j REDIRECT --to-port 8080 &&
|
||||||
|
iptables -t nat -A PREROUTING -i eth1 -p tcp --dport 443 -j REDIRECT --to-port 8080 &&
|
||||||
|
mitmdump --mode transparent@8080 --set showhost=true --set ssl_insecure=true --set confdir=/tmp/mitm -v'
|
||||||
|
```
|
||||||
|
|
||||||
|
The container listened successfully:
|
||||||
|
|
||||||
|
```console
|
||||||
|
Transparent Proxy listening at *:8080.
|
||||||
|
```
|
||||||
|
|
||||||
|
It had an agent-facing address of `192.168.128.7`. Point the agent at
|
||||||
|
it and set DNS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
container exec bb-spike-230t-agent sh -c '
|
||||||
|
ip route replace default via 192.168.128.7 dev eth0
|
||||||
|
printf "nameserver 1.1.1.1\n" > /etc/resolv.conf
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
DNS also needs NAT/forwarding because only TCP 80/443 is redirected:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
container exec bb-spike-230t-mitm sh -c '
|
||||||
|
iptables -t nat -A POSTROUTING -s 192.168.128.0/24 -o eth0 -j MASQUERADE
|
||||||
|
iptables -A FORWARD -i eth1 -o eth0 -j ACCEPT
|
||||||
|
iptables -A FORWARD -i eth0 -o eth1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
An alternative, and likely better, DNS shape is to run a DNS forwarder on
|
||||||
|
the sidecar's host-only IP and point the agent at it. This was tested
|
||||||
|
with `dnsmasq`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
container exec bb-spike-230t-mitm sh -c '
|
||||||
|
apt-get install -y --no-install-recommends dnsmasq
|
||||||
|
cat >/tmp/dnsmasq.conf <<EOF
|
||||||
|
no-daemon
|
||||||
|
listen-address=192.168.128.7
|
||||||
|
bind-interfaces
|
||||||
|
server=1.1.1.1
|
||||||
|
log-queries
|
||||||
|
log-facility=-
|
||||||
|
EOF
|
||||||
|
(dnsmasq --conf-file=/tmp/dnsmasq.conf >/tmp/dnsmasq.log 2>&1 &)
|
||||||
|
sleep 1
|
||||||
|
ss -lunp | grep :53
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
|
||||||
|
```console
|
||||||
|
UNCONN 0 0 192.168.128.7:53 0.0.0.0:* users:(("dnsmasq",pid=515,fd=4))
|
||||||
|
```
|
||||||
|
|
||||||
|
Point the agent to sidecar DNS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
container exec bb-spike-230t-agent sh -c '
|
||||||
|
printf "nameserver 192.168.128.7\n" > /etc/resolv.conf
|
||||||
|
nslookup pypi.org
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
|
||||||
|
```console
|
||||||
|
Server: 192.168.128.7
|
||||||
|
Address: 192.168.128.7:53
|
||||||
|
|
||||||
|
Non-authoritative answer:
|
||||||
|
Name: pypi.org
|
||||||
|
Address: 151.101.128.223
|
||||||
|
Name: pypi.org
|
||||||
|
Address: 151.101.192.223
|
||||||
|
Name: pypi.org
|
||||||
|
Address: 151.101.64.223
|
||||||
|
Name: pypi.org
|
||||||
|
Address: 151.101.0.223
|
||||||
|
```
|
||||||
|
|
||||||
|
Direct HTTP from the agent worked and mitmproxy logged the request:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ container exec bb-spike-230t-agent sh -c \
|
||||||
|
'wget -T 10 -O- http://example.com | head -c 100'
|
||||||
|
Connecting to example.com (172.66.147.243:80)
|
||||||
|
<!doctype html><html lang="en"><head><title>Example Domain</title>
|
||||||
|
```
|
||||||
|
|
||||||
|
Mitmproxy log:
|
||||||
|
|
||||||
|
```console
|
||||||
|
192.168.128.5:39742: GET http://example.com/
|
||||||
|
Host: example.com
|
||||||
|
User-Agent: Wget
|
||||||
|
<< 200 OK 559b
|
||||||
|
```
|
||||||
|
|
||||||
|
After switching the agent to sidecar DNS, direct HTTP still hit
|
||||||
|
mitmproxy:
|
||||||
|
|
||||||
|
```console
|
||||||
|
192.168.128.5:50784: GET http://example.com/
|
||||||
|
Host: example.com
|
||||||
|
User-Agent: Wget
|
||||||
|
<< 200 OK 559b
|
||||||
|
```
|
||||||
|
|
||||||
|
Direct HTTPS from the agent reached mitmproxy but failed certificate
|
||||||
|
verification, as expected when the client does not trust the mitmproxy
|
||||||
|
CA:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ container exec bb-spike-230t-agent sh -c \
|
||||||
|
'wget -T 10 -O- https://pypi.org/simple/pip/ | head -c 100'
|
||||||
|
Connecting to pypi.org (151.101.128.223:443)
|
||||||
|
... certificate verify failed ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Mitmproxy log:
|
||||||
|
|
||||||
|
```console
|
||||||
|
Client TLS handshake failed. The client does not trust the proxy's
|
||||||
|
certificate for pypi.org (tlsv1 alert unknown ca)
|
||||||
|
```
|
||||||
|
|
||||||
|
With verification disabled, the same direct URL succeeded and mitmproxy
|
||||||
|
logged the full HTTPS request:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ container exec bb-spike-230t-agent sh -c \
|
||||||
|
'wget --no-check-certificate -T 10 -O- https://pypi.org/simple/pip/ | head -c 100'
|
||||||
|
Connecting to pypi.org (151.101.128.223:443)
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta name="pypi:repository-version" content="1.4">
|
||||||
|
```
|
||||||
|
|
||||||
|
Mitmproxy log:
|
||||||
|
|
||||||
|
```console
|
||||||
|
192.168.128.5:32802: GET https://pypi.org/simple/pip/
|
||||||
|
Host: pypi.org
|
||||||
|
User-Agent: Wget
|
||||||
|
<< 200 OK 103k
|
||||||
|
```
|
||||||
|
|
||||||
|
After switching the agent to sidecar DNS, direct HTTPS still hit
|
||||||
|
mitmproxy:
|
||||||
|
|
||||||
|
```console
|
||||||
|
192.168.128.5:50254: GET https://pypi.org/simple/pip/
|
||||||
|
Host: pypi.org
|
||||||
|
User-Agent: Wget
|
||||||
|
<< 200 OK 103k
|
||||||
|
```
|
||||||
|
|
||||||
|
Verdict: transparent mitmproxy mode works in this topology. The bot
|
||||||
|
agent would still need the egress CA installed, which bot-bottle already
|
||||||
|
does for explicit proxy mode.
|
||||||
|
|
||||||
|
## Answers
|
||||||
|
|
||||||
|
### Can the sidecar become the agent network's default gateway?
|
||||||
|
|
||||||
|
Not directly through Apple Container's documented CLI. The installed
|
||||||
|
`container run --help` documents `--network
|
||||||
|
<name>[,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE]`; it does not document a
|
||||||
|
gateway option.
|
||||||
|
|
||||||
|
The route can be changed after container start only if the agent has
|
||||||
|
`CAP_NET_ADMIN`. Without it, `ip route replace default via <sidecar>`
|
||||||
|
fails with `Operation not permitted`.
|
||||||
|
|
||||||
|
### Can Apple Container support sidecar forwarding/NAT/transparent proxying?
|
||||||
|
|
||||||
|
Yes. A dual-homed sidecar with `CAP_NET_ADMIN` can enable IP forwarding,
|
||||||
|
set iptables NAT/forwarding rules, and route agent traffic out through
|
||||||
|
the NAT network.
|
||||||
|
|
||||||
|
Transparent mitmproxy interception also works with `PREROUTING`
|
||||||
|
redirects to `mitmdump --mode transparent`.
|
||||||
|
|
||||||
|
### What capabilities/custom image are required?
|
||||||
|
|
||||||
|
At minimum:
|
||||||
|
|
||||||
|
- sidecar needs `CAP_NET_ADMIN`;
|
||||||
|
- sidecar image needs `iptables`/`iproute2` or equivalent nftables
|
||||||
|
tooling;
|
||||||
|
- sidecar should run a DNS listener on its host-only IP, or otherwise
|
||||||
|
provide a controlled resolver path for the agent;
|
||||||
|
- sidecar launcher needs a transparent mode variant;
|
||||||
|
- agent route must be changed to the sidecar's host-only IP;
|
||||||
|
- agent DNS should point to the sidecar DNS listener;
|
||||||
|
- agent must trust the sidecar CA for HTTPS interception.
|
||||||
|
|
||||||
|
The tested agent route mutation required agent `CAP_NET_ADMIN`, which
|
||||||
|
should not be accepted as the final design without a privilege-dropping
|
||||||
|
init/shim story.
|
||||||
|
|
||||||
|
### Can host-level `pf` or vmnet rules replace agent route mutation?
|
||||||
|
|
||||||
|
Not tested. The successful transparent paths did not use host `pf`;
|
||||||
|
they used container-local routing and iptables. Host-level `pf` remains
|
||||||
|
a possible escape hatch if Apple Container cannot set a custom gateway
|
||||||
|
and we reject agent `CAP_NET_ADMIN`.
|
||||||
|
|
||||||
|
### Can existing route policy and DLP semantics be preserved?
|
||||||
|
|
||||||
|
Likely, but not fully validated in this spike. Mitmproxy transparent
|
||||||
|
mode produced normal HTTP flows with correct `Host` values for both
|
||||||
|
HTTP and HTTPS. The existing `egress_addon.py` hooks should still see
|
||||||
|
`flow.request.pretty_host`, method, path, headers, and response bodies.
|
||||||
|
|
||||||
|
But the current sidecar entrypoint only starts `mitmdump` in regular
|
||||||
|
explicit-proxy mode. A real implementation must add a transparent mode
|
||||||
|
launcher and then run the existing egress addon test suite against
|
||||||
|
transparent flows.
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Do not switch `macos-container` to transparent egress yet, but keep it
|
||||||
|
as a plausible implementation path.
|
||||||
|
|
||||||
|
The next implementation spike should focus on removing the agent
|
||||||
|
`CAP_NET_ADMIN` requirement. Acceptable options:
|
||||||
|
|
||||||
|
- find or add an Apple Container-supported default-gateway setting;
|
||||||
|
- start the agent through a tiny root init that sets route/DNS, drops
|
||||||
|
capabilities, and then execs the agent as the normal user;
|
||||||
|
- include a sidecar DNS service and set the agent resolver to the
|
||||||
|
sidecar's host-only IP as part of that init/setup path;
|
||||||
|
- avoid routing mutation by using host/vmnet-level packet redirection;
|
||||||
|
- explicitly decide that route mutation is only a convenience layer and
|
||||||
|
keep explicit proxy env vars for v1.
|
||||||
|
|
||||||
|
Bluntly: transparent egress is feasible, but not production-ready until
|
||||||
|
the agent route can be controlled without leaving network-admin power in
|
||||||
|
the agent runtime.
|
||||||
Reference in New Issue
Block a user