diff --git a/CLAUDE.md b/CLAUDE.md index a7b9296..bf9259e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. - 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. +- **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 diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index 166292b..5fdbd36 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -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: 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 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. - 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. +- **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 > fixed when a container is created, so toggling it recreates the container on the next start. diff --git a/container/Dockerfile b/container/Dockerfile index cefd436..d3be2bc 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -34,6 +34,8 @@ RUN for i in 1 2 3 4 5; do \ cron \ bubblewrap \ socat \ + iproute2 \ + wireguard-tools \ && rm -rf /var/lib/apt/lists/* # `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 # 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 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