WIP: Ship the tools the VPN toggle grants capability for #28

Draft
jknapp wants to merge 4 commits from feat/vpn-tooling into main
Owner

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

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)
jknapp added 1 commit 2026-08-17 19:39:00 +00:00
Ship the tools the VPN toggle grants capability for
Build Container / build-container (pull_request) Successful in 11m28s
00937745f7
`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>
jknapp marked the pull request as work in progress 2026-08-17 20:24:25 +00:00
jknapp added 1 commit 2026-08-17 20:55:51 +00:00
Ship a firewall backend, and correct three claims review disproved
Build App (Preview) / compute-version (pull_request) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m46s
Build App (Preview) / build-linux (pull_request) Successful in 7m22s
Build App (Preview) / build-windows (pull_request) Successful in 7m43s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 11m20s
ab2c75d0b2
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>
jknapp added 1 commit 2026-08-17 23:24:13 +00:00
Ship iptables, not nftables — nftables forfeits macOS
Build App (Preview) / compute-version (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m36s
Build App (Preview) / build-linux (pull_request) Successful in 6m29s
Build App (Preview) / build-windows (pull_request) Successful in 6m59s
Build App (Preview) / prune-previews (pull_request) Successful in 13s
Build Container / build-container (pull_request) Successful in 11m10s
92d64cf252
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>
jknapp added 1 commit 2026-08-18 00:11:22 +00:00
Stop resting the iptables case on a kernel config I cannot verify
Build App (Preview) / compute-version (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build Container / build-container (pull_request) Successful in 29s
Build App (Preview) / build-macos (pull_request) Successful in 2m39s
Build App (Preview) / build-windows (pull_request) Successful in 5m46s
Build App (Preview) / build-linux (pull_request) Successful in 5m59s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
5dd1ab5217
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
Build App (Preview) / create-release (pull_request) Successful in 1s
Build Container / build-container (pull_request) Successful in 29s
Build App (Preview) / build-macos (pull_request) Successful in 2m39s
Build App (Preview) / build-windows (pull_request) Successful in 5m46s
Build App (Preview) / build-linux (pull_request) Successful in 5m59s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
You are not authorized to merge this pull request.
This pull request can be merged automatically.
This pull request is marked as a work in progress.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/vpn-tooling:feat/vpn-tooling
git checkout feat/vpn-tooling
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: CyberCoveLLC/Triple-C#28