So the toggle hands a project authority to configure a network stack it has no tool to configure. The one project where this appeared to work only had ip because MariaDB pulled in iproute2 as a transitive dependency, and wireguard-tools because it was installed by hand — both in the writable layer, so both lost on the next base-image migration. That failure mode is silent: a VPN that will not come up looks the same as one nobody started.
iproute2 + wireguard-tools are ~4.3 MB with dependencies. iptables is deliberately excluded — only a desktop client's killswitch wants it, and those clients need a GUI the container cannot give them.
Docs corrections
Three things users were left to discover the hard way:
The toggle routes nothing.vpn_host_config() returns a cap, a device and a sysctl. It installs no client and touches no route. "The container's default network doesn't route through the VPN automatically" has been reported as a bug; it is the design, and the docs now say so in the first paragraph.
No tunnel survives a restart. The netns is rebuilt on every start and nothing inside reconnects it. /run state persists via the snapshot and makes it look as though the tunnel is still configured, so traffic quietly reverts to the real address with no error.
A full tunnel breaks DNS. Containers resolve through 192.168.65.7 under Docker Desktop, outside the container's own subnet, so a 0.0.0.0/1 + 128.0.0.0/1 default capture swallows it and every lookup dies in a tunnel that cannot carry it. Claude Code then reports a connection failure because it cannot resolve api.anthropic.com. Worth its own note: a health check that fetches https://1.1.1.1/... passes cleanly throughout, since an IP literal needs no resolver. Reproduced and confirmed; the fix is to route RFC1918 via the original gateway and use the provider's resolver.
Testing
Package install verified against ubuntu:24.04 — resolves to libelf1t64 libbpf1 libmnl0 libxtables12 libcap2-bin iproute2 wireguard-tools, 4362 kB, ip and wg on PATH. No iptables binary pulled in (libxtables12 is a library only).
Docs-and-Dockerfile only; no Rust or TypeScript touched.
Next, separately: packaging the working PIA-over-WireGuard setup as a skill so an agent in a VPN-enabled container knows how to bring the tunnel up and route around the DNS trap, rather than rediscovering it.
Follow-up to #27. That PR granted the capability; this ships the tools that use it, and corrects what the docs promise.
## The gap
`vpn_support_enabled` grants `CAP_NET_ADMIN` and `/dev/net/tun`. The image contains neither `ip` nor `wg`:
```
$ dpkg -l | grep -E 'iproute2|iptables|wireguard'
(none of iproute2/iptables/wireguard installed)
```
So the toggle hands a project authority to configure a network stack it has no tool to configure. The one project where this appeared to work only had `ip` because MariaDB pulled in `iproute2` as a transitive dependency, and `wireguard-tools` because it was installed by hand — both in the writable layer, so both lost on the next base-image migration. That failure mode is silent: a VPN that will not come up looks the same as one nobody started.
`iproute2` + `wireguard-tools` are ~4.3 MB with dependencies. `iptables` is deliberately excluded — only a desktop client's killswitch wants it, and those clients need a GUI the container cannot give them.
## Docs corrections
Three things users were left to discover the hard way:
1. **The toggle routes nothing.** `vpn_host_config()` returns a cap, a device and a sysctl. It installs no client and touches no route. "The container's default network doesn't route through the VPN automatically" has been reported as a bug; it is the design, and the docs now say so in the first paragraph.
2. **No tunnel survives a restart.** The netns is rebuilt on every start and nothing inside reconnects it. `/run` state persists via the snapshot and makes it look as though the tunnel is still configured, so traffic quietly reverts to the real address with no error.
3. **A full tunnel breaks DNS.** Containers resolve through `192.168.65.7` under Docker Desktop, outside the container's own subnet, so a `0.0.0.0/1` + `128.0.0.0/1` default capture swallows it and every lookup dies in a tunnel that cannot carry it. Claude Code then reports a connection failure because it cannot resolve `api.anthropic.com`. Worth its own note: a health check that fetches `https://1.1.1.1/...` passes cleanly throughout, since an IP literal needs no resolver. Reproduced and confirmed; the fix is to route RFC1918 via the original gateway and use the provider's resolver.
## Testing
Package install verified against `ubuntu:24.04` — resolves to `libelf1t64 libbpf1 libmnl0 libxtables12 libcap2-bin iproute2 wireguard-tools`, 4362 kB, `ip` and `wg` on PATH. No `iptables` binary pulled in (`libxtables12` is a library only).
Docs-and-Dockerfile only; no Rust or TypeScript touched.
Next, separately: packaging the working PIA-over-WireGuard setup as a skill so an agent in a VPN-enabled container knows how to bring the tunnel up and route around the DNS trap, rather than rediscovering it.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
`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>
Review of #28 found the iptables exclusion was justified by a false premise,
and I confirmed it: `wireguard-tools` declares `Recommends: nftables | iptables`,
`--no-install-recommends` strips it, and `wg-quick`'s add_default() shells out
to a firewall backend with no `type -p` guard. Measured on the image as this PR
shipped it:
[#] iptables-restore -n
/usr/bin/wg-quick: line 32: iptables-restore: command not found
wg-quick EXIT=127
That fires for `AllowedIPs = 0.0.0.0/0` — every stock full-tunnel config from
every provider — not for a desktop client's killswitch as the comment claimed.
Split tunnels are unaffected.
Ship `nftables` rather than `iptables`: wg-quick prefers it (`type -p nft`, so
with both installed iptables is dead weight), it is first in the package's own
Recommends, and it is half the size.
The review's proposed fix stopped there; it does not hold. Adding nftables does
not make wg-quick work on this host, and neither does iptables:
Warning: Extension CONNMARK revision 0 not supported, missing kernel module?
`Table=auto` routes by fwmark and needs xt_CONNMARK from the *host* kernel.
WSL2 has none and containers have no /lib/modules to load one from. So this
fixes native Linux and Docker Desktop for Mac — which other WHP users are on —
and cannot fix Docker Desktop for Windows, where the answer is to add routes
with `ip route` directly. Documented rather than left to be rediscovered.
Also from review:
- "`ip` and `wg` are always present" was false. A project keeps the base image
it was first built from, so this reaches new projects only. Reworded to match
the wording already used for the Playwright libraries, and `/usr/bin/wg` added
to FEATURE_PROBES so an existing project is *told* it is missing VPN tooling
and prompted to migrate, rather than finding out via `wg: command not found`.
- "no client is installed" contradicted shipping `wg` four lines earlier. The
true claim is that no tunnel is configured or started.
- The size figure measured against bare ubuntu:24.04, which over-counts by the
~209 kB of libelf1t64 the real base already has, and covered one arch. Now
measured against the current base on amd64 and stated for arm64 too, per the
standard CLAUDE.md sets for the Playwright layer.
- `/run` persistence conflated two mechanisms: same-container files on a
stop/start, `docker commit` on a recreation. Both stated, plus the corollary
that key material written to /run ends up inside a snapshot image — observed,
a `wg.priv` was already sitting in one.
- The DNS bullet presented a Docker Desktop address as the general case. Now
leads with the mechanism, notes 127.0.0.11 on a user-defined network is
unaffected, and adds the two things the advice omitted: a resolver the tunnel
can reach (or it leaks every query), and pinning the endpoint via the old
gateway (or the tunnel routes through itself).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-review overturned the previous commit's package choice, and verifying it
proved the reviewer right.
`wg-quick` picks nft *unconditionally* when it is present (`type -p nft`, line
241), so installing nftables makes the iptables path unreachable. Its nft
ruleset then needs a third expression family the iptables path does not.
Isolating the rules on this host, the two connmark rules install fine and this
is what fails:
nft add rule ... fib saddr type != local drop
Error: Could not process rule: No such file or directory
That decides it, because of how the hosts differ. LinuxKit's kernel config —
Docker Desktop for Mac, identical on x86_64 and aarch64:
CONFIG_NETFILTER_XT_CONNMARK=y <- the iptables path works
# CONFIG_NFT_FIB_IPV4 is not set <- the nft path does not
So nftables would have broken the platform it was added to fix. With iptables,
full tunnels work on native Linux, Docker Desktop for Mac, and WSL2 from 6.6.
Costs 7,203 kB rather than 5,614 kB on amd64.
That also means the mechanism the previous commit documented was wrong: with
nftables installed `xt_CONNMARK` is never consulted, and the real blocker on
that path is `nft_fib_ipv4`. Rewritten around what actually fails.
A second failure neither round had found: `wireguard-tools` only *Suggests*
`openresolv | resolvconf`, so neither is installed, and every provider's stock
config has a `DNS =` line. That fails in `set_dns()` — before any routing — so
it takes split tunnels down too, contradicting what this PR previously claimed:
[#] resolvconf -a sp -m 0 -x
/usr/bin/wg-quick: line 32: resolvconf: command not found EXIT=127
Not fixed, deliberately: `openresolv` has no installation candidate on noble,
and `resolvconf` resolves only by pulling in systemd-resolved — a resolver
daemon and systemd units, into a container with no systemd. Documented instead.
Smaller corrections from the same review:
- the size caveat blamed ~209 kB of libelf1t64; for this package set the real
over-count is libelf1t64 + netbase. Restated, and arm64 now given against the
real base rather than left as a bare-ubuntu figure.
- the manual-install fallback omitted `iproute2`, so it left the user without
`ip` — the command the tunnel needs most.
- "Without it" had been orphaned from its antecedent by inserted paragraphs and
read as referring to configuring a tunnel.
- the migration probe said "VPN support", presenting VPN as a feature gained to
users who never enabled it. Now names the tools and the toggle.
- "What's Inside the Container" gains a row; the key-material-in-snapshot
hazard was in CLAUDE.md only, and is the one genuinely user-facing warning
here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 3 argued the macOS rationale is stale: that Docker Desktop no longer
builds from linuxkit/linuxkit and has enabled nft_fib_ipv4 since 4.35. I could
not confirm or refute that from a Linux host — searching turned up no version
matrix either way.
But the decision does not depend on it, and the comment should not have implied
it did. `xt_CONNMARK`, which the iptables path needs, was present in every
kernel config examined. `nft_fib_ipv4`, which the nft path needs, was absent
from the config read here and may be present in current Docker Desktop. That
asymmetry is the actual argument: nftables' viability varies by Docker Desktop
version in a way nobody here can pin down, iptables' requirement did not vary
anywhere it was checked. If nft_fib_ipv4 is present this costs 1.6 MB and
nothing else; if it is absent it is the difference between a working full
tunnel and none.
Rewritten to say that, and to say plainly what is verified versus assumed —
this is the third round in which the previous round's central premise did not
survive, and a confidently-worded paragraph is what the next round inherits.
Also from review:
- CLAUDE.md still said "`iptables` is deliberately absent", the opposite of what
this PR now does, contradicting the Dockerfile and both other docs.
- The Dockerfile referenced "the pia-vpn skill", which does not exist on this
branch — the third forward reference of that kind, now gone.
- "full tunnels work on native Linux, Mac and WSL2 6.6" was unconditional and
contradicted ten lines later by the `DNS =` concession, which stops them on
every platform. Reordered so the DNS hurdle is named as the first one.
- The WSL2 gap was written as a permanent platform limitation. It is a stale
install: `wsl --update` moves the host to a current kernel that has the
symbol. That remedy was missing from the user-facing doc.
- The migration probe label carried an internal comma, which `joinFeatures`
renders into a comma-joined list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All checks were successful
Build App (Preview) / compute-version (pull_request) Successful in 6s
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Follow-up to #27. That PR granted the capability; this ships the tools that use it, and corrects what the docs promise.
The gap
vpn_support_enabledgrantsCAP_NET_ADMINand/dev/net/tun. The image contains neitheripnorwg:So the toggle hands a project authority to configure a network stack it has no tool to configure. The one project where this appeared to work only had
ipbecause MariaDB pulled iniproute2as a transitive dependency, andwireguard-toolsbecause it was installed by hand — both in the writable layer, so both lost on the next base-image migration. That failure mode is silent: a VPN that will not come up looks the same as one nobody started.iproute2+wireguard-toolsare ~4.3 MB with dependencies.iptablesis deliberately excluded — only a desktop client's killswitch wants it, and those clients need a GUI the container cannot give them.Docs corrections
Three things users were left to discover the hard way:
The toggle routes nothing.
vpn_host_config()returns a cap, a device and a sysctl. It installs no client and touches no route. "The container's default network doesn't route through the VPN automatically" has been reported as a bug; it is the design, and the docs now say so in the first paragraph.No tunnel survives a restart. The netns is rebuilt on every start and nothing inside reconnects it.
/runstate persists via the snapshot and makes it look as though the tunnel is still configured, so traffic quietly reverts to the real address with no error.A full tunnel breaks DNS. Containers resolve through
192.168.65.7under Docker Desktop, outside the container's own subnet, so a0.0.0.0/1+128.0.0.0/1default capture swallows it and every lookup dies in a tunnel that cannot carry it. Claude Code then reports a connection failure because it cannot resolveapi.anthropic.com. Worth its own note: a health check that fetcheshttps://1.1.1.1/...passes cleanly throughout, since an IP literal needs no resolver. Reproduced and confirmed; the fix is to route RFC1918 via the original gateway and use the provider's resolver.Testing
Package install verified against
ubuntu:24.04— resolves tolibelf1t64 libbpf1 libmnl0 libxtables12 libcap2-bin iproute2 wireguard-tools, 4362 kB,ipandwgon PATH. Noiptablesbinary pulled in (libxtables12is a library only).Docs-and-Dockerfile only; no Rust or TypeScript touched.
Next, separately: packaging the working PIA-over-WireGuard setup as a skill so an agent in a VPN-enabled container knows how to bring the tunnel up and route around the DNS trap, rather than rediscovering it.
🤖 Generated with Claude Code
Re-review overturned the previous commit's package choice, and verifying it proved the reviewer right. `wg-quick` picks nft *unconditionally* when it is present (`type -p nft`, line 241), so installing nftables makes the iptables path unreachable. Its nft ruleset then needs a third expression family the iptables path does not. Isolating the rules on this host, the two connmark rules install fine and this is what fails: nft add rule ... fib saddr type != local drop Error: Could not process rule: No such file or directory That decides it, because of how the hosts differ. LinuxKit's kernel config — Docker Desktop for Mac, identical on x86_64 and aarch64: CONFIG_NETFILTER_XT_CONNMARK=y <- the iptables path works # CONFIG_NFT_FIB_IPV4 is not set <- the nft path does not So nftables would have broken the platform it was added to fix. With iptables, full tunnels work on native Linux, Docker Desktop for Mac, and WSL2 from 6.6. Costs 7,203 kB rather than 5,614 kB on amd64. That also means the mechanism the previous commit documented was wrong: with nftables installed `xt_CONNMARK` is never consulted, and the real blocker on that path is `nft_fib_ipv4`. Rewritten around what actually fails. A second failure neither round had found: `wireguard-tools` only *Suggests* `openresolv | resolvconf`, so neither is installed, and every provider's stock config has a `DNS =` line. That fails in `set_dns()` — before any routing — so it takes split tunnels down too, contradicting what this PR previously claimed: [#] resolvconf -a sp -m 0 -x /usr/bin/wg-quick: line 32: resolvconf: command not found EXIT=127 Not fixed, deliberately: `openresolv` has no installation candidate on noble, and `resolvconf` resolves only by pulling in systemd-resolved — a resolver daemon and systemd units, into a container with no systemd. Documented instead. Smaller corrections from the same review: - the size caveat blamed ~209 kB of libelf1t64; for this package set the real over-count is libelf1t64 + netbase. Restated, and arm64 now given against the real base rather than left as a bare-ubuntu figure. - the manual-install fallback omitted `iproute2`, so it left the user without `ip` — the command the tunnel needs most. - "Without it" had been orphaned from its antecedent by inserted paragraphs and read as referring to configuring a tunnel. - the migration probe said "VPN support", presenting VPN as a feature gained to users who never enabled it. Now names the tools and the toggle. - "What's Inside the Container" gains a row; the key-material-in-snapshot hazard was in CLAUDE.md only, and is the one genuinely user-facing warning here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.