From 00937745f7f9923acb2c6b8bc9c3655e1a147c22 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 17 Aug 2026 12:38:37 -0700 Subject: [PATCH 1/4] Ship the tools the VPN toggle grants capability for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- CLAUDE.md | 12 ++++++++++++ HOW-TO-USE.md | 23 ++++++++++++++++++++++- container/Dockerfile | 19 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) 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 -- 2.52.0 From ab2c75d0b290f5c0754334f0065babff8e1669e5 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 17 Aug 2026 13:55:44 -0700 Subject: [PATCH 2/4] Ship a firewall backend, and correct three claims review disproved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 22 +++++++++--- HOW-TO-USE.md | 50 ++++++++++++++++---------- app/src-tauri/src/docker/migration.rs | 1 + container/Dockerfile | 52 ++++++++++++++++++++++----- 4 files changed, 94 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bf9259e..6d2454e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -203,7 +203,8 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li ### Container (`container/`) - **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed, plus the shared - libraries a browser links against (see below) + libraries a browser links against (see below) and the VPN tooling the `vpn_support_enabled` + toggle grants capability for (`iproute2`, `wireguard-tools`, `nftables`) - **Browser runtime libraries are baked in; browser *binaries* are not.** A layer runs `npx --yes playwright@latest install-deps chromium` as root, so Playwright names its own dependencies and the list cannot rot against Ubuntu 24.04's `t64` renames or a new Chromium @@ -316,9 +317,22 @@ container is created once by a very long function where a dropped capability is 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. + service manager runs inside, so a tunnel never survives stop/start or recreation — while leftover + `/run` state makes it look as though it did. Note the two different mechanisms: `/run` is in the + writable layer, so on a stop/start it is simply the same container's files, and on a recreation + `docker commit` has carried it into the snapshot. Traffic silently reverts to the real address. + Any future autostart or killswitch work starts here. +- **`/run` riding the snapshot means a VPN client's key material can end up in an image.** Verified: + a fresh container off the whp snapshot already contained the `wg.priv` a previous tunnel left in + `/run`. Anything writing key material there inherits the problem — the same `docker commit` + hazard as `triple-c.git-token-hash` and the custom-env fingerprint, in a directory that looks + ephemeral and is not. A VPN client that does this should delete its key on teardown. +- **`wg-quick` full tunnels need `xt_CONNMARK` from the host kernel**, which WSL2 does not have and + a container cannot load; `Recommends: nftables | iptables` is also stripped by + `--no-install-recommends`, so `nftables` is baked explicitly. See the Dockerfile comment — the + short version is that shipping the backend fixes native Linux and Docker Desktop for Mac, nothing + fixes Docker Desktop for Windows, and adding the routes directly with `ip route` sidesteps it on + all three. ### Container Lifecycle diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index 5fdbd36..cd8afd6 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -475,13 +475,18 @@ 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. The `ip` and `wg` commands are always present to use them. This is -**off by default**. +sysctl that WireGuard requires. This is **off by default**. + +The `ip`, `wg` and `nft` commands ship in the container image so there is something able to use +them. If your project's container was created from an older base image it will not have them, and +`wg` will simply not be found — **migrating the project onto the current base image** is what picks +them up. Installing them by hand with `sudo apt install wireguard-tools` works in the meantime, but +lives in the writable layer, so it is undone by a **Reset** and by a migration. **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 +redirected, and no tunnel is configured 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. +configuring a tunnel 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 @@ -504,20 +509,29 @@ Things worth knowing: - 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. + starts, and there is no service manager inside to reconnect anything. Leftover state under `/run` + makes it *look* like the tunnel is still configured — that directory is in the container's + writable layer, so it is simply still there after a stop/start, and `docker commit` carries it + into the snapshot that a recreation is built from. Either way 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.** Your + resolver is whatever `/etc/resolv.conf` says, and if that address is outside the container's own + subnet then 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. Under Docker Desktop it is + `192.168.65.7`, which is exactly that case; on a user-defined Docker network it is `127.0.0.11`, + which is loopback and unaffected. Check yours rather than assuming. The symptom when it bites 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 give the tunnel a resolver it can actually reach, normally the VPN provider's own, + or you have a tunnel that leaks every DNS query outside itself. Also pin the VPN endpoint's own + address via the original gateway, or the tunnel's encrypted packets try to route through the + tunnel. Note that a health check which fetches an IP literal such as `1.1.1.1` passes cleanly + while DNS is broken — resolve a name instead. +- **`wg-quick` cannot bring up a full tunnel on Docker Desktop for Windows.** Its `Table=auto` mode + routes by firewall mark and needs `xt_CONNMARK` from the host kernel, which WSL2's does not have + and a container cannot load. Split tunnels (a specific `AllowedIPs`) work fine, as does adding + the routes yourself with `ip route`. Native Linux and Docker Desktop for Mac are unaffected. > 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/app/src-tauri/src/docker/migration.rs b/app/src-tauri/src/docker/migration.rs index 6e9fecf..c1b6b1e 100644 --- a/app/src-tauri/src/docker/migration.rs +++ b/app/src-tauri/src/docker/migration.rs @@ -135,6 +135,7 @@ pub const FEATURE_PROBES: &[(&str, &str)] = &[ ("/usr/local/bin/triple-c-task-runner", "Scheduled task runner"), ("/usr/local/bin/triple-c-sso-refresh", "AWS SSO auto-refresh"), ("/opt/mission-control", "Mission Control (Flight Control)"), + ("/usr/bin/wg", "VPN support (WireGuard tools)"), ]; /// Headroom demanded on Docker's storage backend on top of the measured diff --git a/container/Dockerfile b/container/Dockerfile index d3be2bc..aaa31f6 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -36,6 +36,7 @@ RUN for i in 1 2 3 4 5; do \ socat \ iproute2 \ wireguard-tools \ + nftables \ && rm -rf /var/lib/apt/lists/* # `libnss3-tools` above provides `certutil`. Chrome/Chromium read neither @@ -44,22 +45,55 @@ 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. +# `iproute2`, `wireguard-tools` and `nftables` 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. +# never started. # -# `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. +# Measured against the *current base image*, not a bare ubuntu:24.04 — the base +# already ships libelf1t64, so measuring on bare ubuntu over-counts by ~209 kB: +# +9 packages, 5,614 kB on amd64 (4,153 kB of that is iproute2+wireguard-tools, +# 1,461 kB is nftables). The same set on arm64 is 7,422 kB, measured against +# ubuntu:24.04 since the arm64 base is not cached here. +# +# ## Why `nftables` specifically +# +# `wireguard-tools` declares `Recommends: nftables | iptables`, which the +# `--no-install-recommends` above strips. That is not cosmetic: `wg-quick`'s +# `add_default()` runs whenever a config has `AllowedIPs = 0.0.0.0/0` — i.e. +# every stock full-tunnel config every provider hands out — and it shells out to +# a firewall backend with no `type -p` guard. Measured without one: +# +# [#] iptables-restore -n +# /usr/bin/wg-quick: line 32: iptables-restore: command not found +# wg-quick EXIT=127 (interface rolled back, split tunnels unaffected) +# +# `nftables` rather than `iptables` because `wg-quick` prefers it (`if type -p +# nft`, so with both installed iptables is dead weight), it is the first +# alternative in the package's own Recommends, and it is roughly half the size. +# +# This does NOT make `wg-quick`'s full-tunnel mode work everywhere. `Table=auto` +# routes by fwmark and needs connection-mark tracking from the *host* kernel: +# +# Warning: Extension CONNMARK revision 0 not supported, missing kernel module? +# +# WSL2's kernel has no `xt_CONNMARK` and containers have no /lib/modules to load +# one from, so on Docker Desktop for Windows `wg-quick up` on a full tunnel fails +# regardless of what is installed here. Native Linux and Docker Desktop for Mac +# have it. Shipping the backend is what makes the difference on those two; +# nothing shipped here can make the difference on WSL2, where the way out is to +# add the routes with `ip route` instead of going through `wg-quick` at all. +# +# `iptables` is deliberately still NOT here: with `nftables` present `wg-quick` +# never reaches for it, so it would add size and firewall surface for nothing. # 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 \ -- 2.52.0 From 92d64cf2526d9e3e8aff82e9f0f4e5faf3e14733 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 17 Aug 2026 16:24:07 -0700 Subject: [PATCH 3/4] =?UTF-8?q?Ship=20iptables,=20not=20nftables=20?= =?UTF-8?q?=E2=80=94=20nftables=20forfeits=20macOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 21 ++++++--- HOW-TO-USE.md | 28 +++++++---- app/src-tauri/src/docker/migration.rs | 2 +- container/Dockerfile | 68 +++++++++++++++++---------- 4 files changed, 78 insertions(+), 41 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6d2454e..3baccd7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,7 +204,7 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li - **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed, plus the shared libraries a browser links against (see below) and the VPN tooling the `vpn_support_enabled` - toggle grants capability for (`iproute2`, `wireguard-tools`, `nftables`) + toggle grants capability for (`iproute2`, `wireguard-tools`, `iptables`) - **Browser runtime libraries are baked in; browser *binaries* are not.** A layer runs `npx --yes playwright@latest install-deps chromium` as root, so Playwright names its own dependencies and the list cannot rot against Ubuntu 24.04's `t64` renames or a new Chromium @@ -327,12 +327,19 @@ container is created once by a very long function where a dropped capability is `/run`. Anything writing key material there inherits the problem — the same `docker commit` hazard as `triple-c.git-token-hash` and the custom-env fingerprint, in a directory that looks ephemeral and is not. A VPN client that does this should delete its key on teardown. -- **`wg-quick` full tunnels need `xt_CONNMARK` from the host kernel**, which WSL2 does not have and - a container cannot load; `Recommends: nftables | iptables` is also stripped by - `--no-install-recommends`, so `nftables` is baked explicitly. See the Dockerfile comment — the - short version is that shipping the backend fixes native Linux and Docker Desktop for Mac, nothing - fixes Docker Desktop for Windows, and adding the routes directly with `ip route` sidesteps it on - all three. +- **`iptables` is baked, and picking `nftables` instead would have been wrong.** `Recommends: + nftables | iptables` is stripped by `--no-install-recommends`, and `wg-quick` needs a backend for + any `AllowedIPs = 0.0.0.0/0`. `nftables` is the tempting choice — preferred by `wg-quick`, half + the size — but `wg-quick` picks nft *unconditionally* when present, and its nft ruleset needs + `nft_fib_ipv4`, which LinuxKit (Docker Desktop for Mac) does not build while it *does* build + `xt_CONNMARK`. Shipping nftables would therefore have forfeited Mac. See the Dockerfile comment; + the kernel-config evidence is quoted there. +- **Two `wg-quick` failures remain, and only one is ours to fix.** Full tunnels still need + `xt_CONNMARK`, which WSL2 before 6.6 lacks — nothing installable changes that. And every + provider's stock config carries a `DNS =` line that fails in `set_dns()` before any routing, so it + breaks split tunnels too; `openresolv` has no candidate on noble and `resolvconf` drags in + systemd-resolved, so that one is documented rather than fixed. Driving `wg` and `ip route` + directly avoids both, which is what the skill does. ### Container Lifecycle diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index cd8afd6..38514a8 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -477,19 +477,19 @@ When enabled, the container is given the three things a VPN client needs to buil 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**. -The `ip`, `wg` and `nft` commands ship in the container image so there is something able to use +The `ip`, `wg` and `iptables` commands ship in the container image so there is something able to use them. If your project's container was created from an older base image it will not have them, and `wg` will simply not be found — **migrating the project onto the current base image** is what picks -them up. Installing them by hand with `sudo apt install wireguard-tools` works in the meantime, but -lives in the writable layer, so it is undone by a **Reset** and by a migration. +them up. `sudo apt install iproute2 wireguard-tools iptables` works in the meantime, but lives in +the writable layer, so it is undone by a **Reset** and by a migration. **This setting makes a tunnel possible; it does not make one.** Nothing is connected, no traffic is redirected, and no tunnel is configured 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 — configuring a tunnel 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 +With the setting **off**, a client such as PIA 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 and no permission to add an interface or a route, and most clients report that as a generic timeout rather than a permissions error. @@ -528,10 +528,19 @@ Things worth knowing: address via the original gateway, or the tunnel's encrypted packets try to route through the tunnel. Note that a health check which fetches an IP literal such as `1.1.1.1` passes cleanly while DNS is broken — resolve a name instead. -- **`wg-quick` cannot bring up a full tunnel on Docker Desktop for Windows.** Its `Table=auto` mode - routes by firewall mark and needs `xt_CONNMARK` from the host kernel, which WSL2's does not have - and a container cannot load. Split tunnels (a specific `AllowedIPs`) work fine, as does adding - the routes yourself with `ip route`. Native Linux and Docker Desktop for Mac are unaffected. +- **Delete a client's key material when you tear a tunnel down.** Anything written under `/run` is + in the container's writable layer, and recreating or migrating the project runs `docker commit` + over it — so a WireGuard private key left there gets baked into the project's snapshot image and + copied forward from then on. This is not hypothetical; it has already happened here. +- **Strip the `DNS =` line from a provider's `.conf` before `wg-quick up`.** Every commercial + provider ships one, and `wg-quick` hands it to `resolvconf`, which is not installed — so it fails + at `resolvconf: command not found` and deletes the interface again. This happens before any + routing, so it takes **split tunnels down too**. Set the resolver another way instead, or drive + `wg` and `ip route` directly rather than going through `wg-quick`. +- **`wg-quick` full tunnels also need `xt_CONNMARK` from the host kernel.** Native Linux, Docker + Desktop for Mac and WSL2 kernels from 6.6 have it; older WSL2 kernels do not, and a container + cannot load one. There the answer is again to add the routes yourself with `ip route`, which + needs no firewall backend on any platform. > 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. @@ -1291,6 +1300,7 @@ The sandbox container (Ubuntu 24.04) comes pre-installed with: | ruff | Latest | Python linter/formatter | | Rust | Stable | Rust development (via rustup) | | Docker CLI | Latest | Container management (when spawning is enabled) | +| iproute2, WireGuard tools, iptables | Latest | Building a tunnel (when VPN Support is enabled) | | git | Latest | Version control | | GitHub CLI (gh) | Latest | GitHub integration | | AWS CLI | v2 | AWS services and Bedrock | diff --git a/app/src-tauri/src/docker/migration.rs b/app/src-tauri/src/docker/migration.rs index c1b6b1e..1b0b580 100644 --- a/app/src-tauri/src/docker/migration.rs +++ b/app/src-tauri/src/docker/migration.rs @@ -135,7 +135,7 @@ pub const FEATURE_PROBES: &[(&str, &str)] = &[ ("/usr/local/bin/triple-c-task-runner", "Scheduled task runner"), ("/usr/local/bin/triple-c-sso-refresh", "AWS SSO auto-refresh"), ("/opt/mission-control", "Mission Control (Flight Control)"), - ("/usr/bin/wg", "VPN support (WireGuard tools)"), + ("/usr/bin/wg", "VPN tooling (WireGuard, for the VPN Support toggle)"), ]; /// Headroom demanded on Docker's storage backend on top of the measured diff --git a/container/Dockerfile b/container/Dockerfile index aaa31f6..86aea02 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -36,7 +36,7 @@ RUN for i in 1 2 3 4 5; do \ socat \ iproute2 \ wireguard-tools \ - nftables \ + iptables \ && rm -rf /var/lib/apt/lists/* # `libnss3-tools` above provides `certutil`. Chrome/Chromium read neither @@ -45,7 +45,7 @@ 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`, `wireguard-tools` and `nftables` above are what the VPN support +# `iproute2`, `wireguard-tools` and `iptables` 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 @@ -58,42 +58,62 @@ RUN for i in 1 2 3 4 5; do \ # — silently, since a VPN that fails to come up looks exactly like one that was # never started. # -# Measured against the *current base image*, not a bare ubuntu:24.04 — the base -# already ships libelf1t64, so measuring on bare ubuntu over-counts by ~209 kB: -# +9 packages, 5,614 kB on amd64 (4,153 kB of that is iproute2+wireguard-tools, -# 1,461 kB is nftables). The same set on arm64 is 7,422 kB, measured against -# ubuntu:24.04 since the arm64 base is not cached here. +# Measured against the *current base image*, since a bare ubuntu:24.04 also +# pulls libelf1t64 and netbase, which this base already has, and so over-reports +# by ~258 kB: **+12 packages, 7,203 kB on amd64**. The same set on arm64 is +# ~14.4 MB — the package list is identical on both arches, the binaries are +# simply larger (measured as 14.7 MB on arm64 ubuntu:24.04, less that 258 kB). # -# ## Why `nftables` specifically +# ## Why `iptables`, and not `nftables` # # `wireguard-tools` declares `Recommends: nftables | iptables`, which the # `--no-install-recommends` above strips. That is not cosmetic: `wg-quick`'s # `add_default()` runs whenever a config has `AllowedIPs = 0.0.0.0/0` — i.e. # every stock full-tunnel config every provider hands out — and it shells out to -# a firewall backend with no `type -p` guard. Measured without one: +# a firewall backend with no `type -p` guard. Measured with neither installed: # # [#] iptables-restore -n # /usr/bin/wg-quick: line 32: iptables-restore: command not found -# wg-quick EXIT=127 (interface rolled back, split tunnels unaffected) +# wg-quick EXIT=127 # -# `nftables` rather than `iptables` because `wg-quick` prefers it (`if type -p -# nft`, so with both installed iptables is dead weight), it is the first -# alternative in the package's own Recommends, and it is roughly half the size. +# `nftables` looks like the better pick — wg-quick prefers it, it is first in +# that Recommends, it is half the size — and it is the wrong one. wg-quick picks +# nft *unconditionally* when present (`if type -p nft`, line 241), so installing +# it makes the iptables path unreachable; and its nft ruleset needs three +# expression families where the iptables path needs one. Isolating them on a +# WSL2 host, the two connmark rules install fine and this is what fails: # -# This does NOT make `wg-quick`'s full-tunnel mode work everywhere. `Table=auto` -# routes by fwmark and needs connection-mark tracking from the *host* kernel: +# nft add rule ... fib saddr type != local drop +# Error: Could not process rule: No such file or directory +# ^^^^^^^^^^^^^^ needs nft_fib_ipv4 # -# Warning: Extension CONNMARK revision 0 not supported, missing kernel module? +# That matters because of how the two hosts we ship to are configured. From +# LinuxKit's kernel config — Docker Desktop for Mac, identical on both arches: # -# WSL2's kernel has no `xt_CONNMARK` and containers have no /lib/modules to load -# one from, so on Docker Desktop for Windows `wg-quick up` on a full tunnel fails -# regardless of what is installed here. Native Linux and Docker Desktop for Mac -# have it. Shipping the backend is what makes the difference on those two; -# nothing shipped here can make the difference on WSL2, where the way out is to -# add the routes with `ip route` instead of going through `wg-quick` at all. +# CONFIG_NETFILTER_XT_CONNMARK=y <- the iptables path works +# # CONFIG_NFT_FIB_IPV4 is not set <- the nft path does not # -# `iptables` is deliberately still NOT here: with `nftables` present `wg-quick` -# never reaches for it, so it would add size and firewall surface for nothing. +# So shipping `nftables` would forfeit the platform it was meant to fix. With +# `iptables`, full tunnels work on native Linux, on Docker Desktop for Mac, and +# on WSL2 kernels from 6.6 (which added xt_CONNMARK as a module). Only WSL2 +# older than that is left out, and nothing installable here changes it — the way +# out there is to add the routes with `ip route` instead of using `wg-quick`, +# which is what the pia-vpn skill does on every platform. +# +# ## What this still does not fix +# +# `wireguard-tools` only *Suggests* `openresolv | resolvconf`, so neither is +# installed, and every provider's stock config carries a `DNS =` line. That +# fails in `set_dns()`, *before* the firewall step, so it takes split tunnels +# down too: +# +# [#] resolvconf -a wg0 -m 0 -x +# /usr/bin/wg-quick: line 32: resolvconf: command not found +# +# Deliberately not fixed here: `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. Strip the +# `DNS =` line and set the resolver another way. Documented in HOW-TO-USE.md. # 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 \ -- 2.52.0 From 5dd1ab5217b01108ead52810b7ff101b6943de7c Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 17 Aug 2026 17:11:17 -0700 Subject: [PATCH 4/4] Stop resting the iptables case on a kernel config I cannot verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 3 ++- HOW-TO-USE.md | 9 +++++---- app/src-tauri/src/docker/migration.rs | 2 +- container/Dockerfile | 27 +++++++++++++++++---------- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3baccd7..251f2c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -315,7 +315,8 @@ container is created once by a very long function where a dropped capability is - **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. + and no error that points at why. `iptables` is included and `nftables` deliberately is not; see + the Dockerfile comment for why that way round. - **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 leftover `/run` state makes it look as though it did. Note the two different mechanisms: `/run` is in the diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index 38514a8..85f53b6 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -537,10 +537,11 @@ Things worth knowing: at `resolvconf: command not found` and deletes the interface again. This happens before any routing, so it takes **split tunnels down too**. Set the resolver another way instead, or drive `wg` and `ip route` directly rather than going through `wg-quick`. -- **`wg-quick` full tunnels also need `xt_CONNMARK` from the host kernel.** Native Linux, Docker - Desktop for Mac and WSL2 kernels from 6.6 have it; older WSL2 kernels do not, and a container - cannot load one. There the answer is again to add the routes yourself with `ip route`, which - needs no firewall backend on any platform. +- **`wg-quick` full tunnels additionally need `xt_CONNMARK` from the host kernel.** WSL2 kernels + before 6.6 do not have it and a container cannot load one — on Windows, `wsl --update` moves you + to a current kernel, which does. Failing that, add the routes yourself with `ip route`, which + needs no firewall backend on any platform. Note this is the *second* hurdle: clear the `DNS =` + one above first, or you will not reach this. > 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/app/src-tauri/src/docker/migration.rs b/app/src-tauri/src/docker/migration.rs index 1b0b580..a08776a 100644 --- a/app/src-tauri/src/docker/migration.rs +++ b/app/src-tauri/src/docker/migration.rs @@ -135,7 +135,7 @@ pub const FEATURE_PROBES: &[(&str, &str)] = &[ ("/usr/local/bin/triple-c-task-runner", "Scheduled task runner"), ("/usr/local/bin/triple-c-sso-refresh", "AWS SSO auto-refresh"), ("/opt/mission-control", "Mission Control (Flight Control)"), - ("/usr/bin/wg", "VPN tooling (WireGuard, for the VPN Support toggle)"), + ("/usr/bin/wg", "VPN tooling for the VPN Support toggle (WireGuard)"), ]; /// Headroom demanded on Docker's storage backend on top of the measured diff --git a/container/Dockerfile b/container/Dockerfile index 86aea02..b94b76c 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -87,18 +87,25 @@ RUN for i in 1 2 3 4 5; do \ # Error: Could not process rule: No such file or directory # ^^^^^^^^^^^^^^ needs nft_fib_ipv4 # -# That matters because of how the two hosts we ship to are configured. From -# LinuxKit's kernel config — Docker Desktop for Mac, identical on both arches: +# The choice therefore turns on which kernel symbol each path needs, and the two +# are not equally safe to bet on. `xt_CONNMARK` (iptables) was present in every +# kernel config examined — LinuxKit's for both arches, and WSL2's from 6.6. +# `nft_fib_ipv4` (nftables) was absent from the LinuxKit config read here, and a +# later review argued Docker Desktop has since enabled it and no longer builds +# from that config at all. That may well be true; it could not be settled from a +# Linux host, and it is the point: nftables' viability varies by Docker Desktop +# version in a way nobody here can pin down, while iptables' requirement did not +# vary anywhere it was checked. # -# CONFIG_NETFILTER_XT_CONNMARK=y <- the iptables path works -# # CONFIG_NFT_FIB_IPV4 is not set <- the nft path does not +# So `iptables` is chosen for being robust to that uncertainty rather than for +# beating nftables on any particular host. If nft_fib_ipv4 is present, wg-quick +# never reaches the iptables path and this costs 1.6 MB and nothing else; if it +# is absent, this is the difference between a working full tunnel and none. # -# So shipping `nftables` would forfeit the platform it was meant to fix. With -# `iptables`, full tunnels work on native Linux, on Docker Desktop for Mac, and -# on WSL2 kernels from 6.6 (which added xt_CONNMARK as a module). Only WSL2 -# older than that is left out, and nothing installable here changes it — the way -# out there is to add the routes with `ip route` instead of using `wg-quick`, -# which is what the pia-vpn skill does on every platform. +# The residual gap is WSL2 before 6.6, which has neither symbol. Nothing +# installable in the container changes that — but `wsl --update` does, and moves +# the host to a far newer kernel. Add the routes with `ip route` in the meantime; +# that needs no firewall backend on any platform. # # ## What this still does not fix # -- 2.52.0