Ship the tools the VPN toggle grants capability for
Build Container / build-container (pull_request) Successful in 11m28s

`vpn_support_enabled` hands a project CAP_NET_ADMIN and /dev/net/tun, and the
image then contains no `ip` and no `wg` — a capability with nothing able to
exercise it. Bake `iproute2` and `wireguard-tools` (~4.3 MB with deps).

They belong in the image rather than a runtime install for the reason the
Dockerfile already gives for the Playwright libraries: the writable layer is
lost on base-image migration. A hand-installed `wg` works until an upgrade and
then vanishes, which presents as a tunnel that will not come up rather than as
a missing package. One project only had `ip` at all because MariaDB pulled in
iproute2 as a transitive dependency.

`iptables` stays out. Only a desktop client's killswitch wants it, and those
clients need a GUI the container cannot provide.

Also correct three things the docs left users to discover:

- the toggle grants capability and routes nothing, which is being reported as
  the default network "not routing through the VPN automatically"
- no tunnel survives a restart, and `/run` state riding the snapshot makes it
  look as though one did while traffic goes out the real address
- a full tunnel captures the Docker resolver, which sits outside the
  container's subnet, and takes DNS down with it — Claude Code then reports a
  connection failure because it cannot resolve api.anthropic.com, and a health
  check aimed at an IP literal passes throughout

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 12:38:37 -07:00
co-authored by Claude Opus 5
parent 01e72e4785
commit 00937745f7
3 changed files with 53 additions and 1 deletions
+12
View File
@@ -307,6 +307,18 @@ container is created once by a very long function where a dropped capability is
container and make the switch impossible to turn off. container and make the switch impossible to turn off.
- Off is byte-identical to a container created before the feature existed, and a missing label - Off is byte-identical to a container created before the feature existed, and a missing label
reads as `false`, so no existing project is churned. reads as `false`, so no existing project is churned.
- **The toggle grants capability and stops there — it routes nothing.** `vpn_host_config()` returns
a cap, a device and a sysctl; no client is installed, no route is touched, no tunnel is started
or restored. Users read the name as "turn the VPN on" and report the default network not routing
through it as a bug. It isn't, and the docs say so explicitly; keep it that way.
- **The tooling is baked, not installed at runtime.** `iproute2` and `wireguard-tools` are in
`container/Dockerfile` because a runtime install lands in the writable layer and is lost on
base-image migration — leaving a project holding the capability with nothing able to exercise it,
and no error that points at why. `iptables` is deliberately absent; see the Dockerfile comment.
- **Anything built on this fails open.** The network namespace is rebuilt on every start and no
service manager runs inside, so a tunnel never survives stop/start or recreation while `/run`
state persists through the snapshot and makes it look as though it did. Traffic silently reverts
to the real address. Any future autostart or killswitch work starts here.
### Container Lifecycle ### Container Lifecycle
+22 -1
View File
@@ -475,7 +475,13 @@ When enabled, the host Docker socket is mounted into the container so Claude Cod
When enabled, the container is given the three things a VPN client needs to build a tunnel: When enabled, the container is given the three things a VPN client needs to build a tunnel:
the `NET_ADMIN` capability, the `/dev/net/tun` device, and the `net.ipv4.conf.all.src_valid_mark` the `NET_ADMIN` capability, the `/dev/net/tun` device, and the `net.ipv4.conf.all.src_valid_mark`
sysctl that WireGuard requires. This is **off by default**. sysctl that WireGuard requires. The `ip` and `wg` commands are always present to use them. This is
**off by default**.
**This setting makes a tunnel possible; it does not make one.** Nothing is connected, no traffic is
redirected, and no client is installed or started on your behalf. Enabling it and expecting the
container's traffic to start leaving through a VPN is the most common misreading of what it does —
installing a client and routing traffic into it remains yours to do.
Without it, a client such as PIA, WireGuard or OpenVPN installs and its daemon starts normally, but Without it, a client such as PIA, WireGuard or OpenVPN installs and its daemon starts normally, but
the connection attempt **hangs until it times out** — a default container has no tun device to open the connection attempt **hangs until it times out** — a default container has no tun device to open
@@ -497,6 +503,21 @@ Things worth knowing:
**start**, with an error naming `/dev/net/tun` and pointing back at this setting. **start**, with an error naming `/dev/net/tun` and pointing back at this setting.
- A VPN client's kill switch applies to everything in the container, Claude Code included. If the - A VPN client's kill switch applies to everything in the container, Claude Code included. If the
tunnel drops, expect API calls to fail until it reconnects or the kill switch is turned off. tunnel drops, expect API calls to fail until it reconnects or the kill switch is turned off.
- **No tunnel survives a restart.** The network namespace is built fresh every time the container
starts, and there is no service manager inside to reconnect anything. Files under `/run` may
persist via the snapshot and make it *look* like the tunnel is still configured, but after any
stop/start, Reset or recreation the interface and its routes are gone and traffic goes out your
real address again — with no error and nothing visibly different. Re-establish it after every
start, and check rather than assume.
- **A full tunnel breaks DNS unless the client is told to leave private ranges alone.** Containers
resolve through an address on the Docker network (`192.168.65.7` under Docker Desktop) that sits
outside the container's own subnet, so a default route of `0.0.0.0/0` — or a `0.0.0.0/1` plus
`128.0.0.0/1` pair — captures it and sends every lookup into a tunnel that cannot carry it. The
symptom is total: Claude Code reports it cannot connect, because it cannot resolve
`api.anthropic.com`. Route `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` and `169.254.0.0/16`
via the original gateway, and use the VPN provider's own resolver for everything else. Note that
a health check which fetches an IP literal such as `1.1.1.1` passes cleanly while this is broken —
resolve a name instead.
> This setting can only be changed when the container is stopped. Capabilities and devices are > This setting can only be changed when the container is stopped. Capabilities and devices are
> fixed when a container is created, so toggling it recreates the container on the next start. > fixed when a container is created, so toggling it recreates the container on the next start.
+19
View File
@@ -34,6 +34,8 @@ RUN for i in 1 2 3 4 5; do \
cron \ cron \
bubblewrap \ bubblewrap \
socat \ socat \
iproute2 \
wireguard-tools \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# `libnss3-tools` above provides `certutil`. Chrome/Chromium read neither # `libnss3-tools` above provides `certutil`. Chrome/Chromium read neither
@@ -42,6 +44,23 @@ RUN for i in 1 2 3 4 5; do \
# corporate CA, no matter what the system trust store says. entrypoint.sh # corporate CA, no matter what the system trust store says. entrypoint.sh
# degrades to a warning if it is ever missing. # degrades to a warning if it is ever missing.
# `iproute2` and `wireguard-tools` above are what the VPN support toggle
# (`vpn_support_enabled`) grants capability *for*. That toggle hands a project
# CAP_NET_ADMIN and /dev/net/tun; without `ip` there is then no way to add a
# route, and without `wg` no way to build the tunnel those two exist to serve —
# a capability with nothing able to use it.
#
# They are baked rather than left to a runtime `apt-get install` for the same
# reason as the Playwright libraries below: the writable layer is re-paid after
# every Reset and lost on base-image migration. A hand-installed `wg` therefore
# works right up until an upgrade, then disappears and takes the tunnel with it
# — silently, since a VPN that fails to come up looks exactly like one that was
# never started. Together they are ~4.3 MB including dependencies.
#
# `iptables` is deliberately NOT here. The only thing that wants it is a desktop
# VPN client's killswitch, and those clients need a GUI that a container has no
# way to give them; leaving it out keeps the reach of CAP_NET_ADMIN smaller.
# Remove default ubuntu user to free UID 1000 for host-user remapping # Remove default ubuntu user to free UID 1000 for host-user remapping
RUN if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu 2>/dev/null || userdel ubuntu; fi \ RUN if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu 2>/dev/null || userdel ubuntu; fi \
&& if getent group ubuntu >/dev/null 2>&1; then groupdel ubuntu 2>/dev/null || true; fi && if getent group ubuntu >/dev/null 2>&1; then groupdel ubuntu 2>/dev/null || true; fi