Compare commits

...
3 Commits
Author SHA1 Message Date
jknapp d971326e4e Merge pull request 'Ship the tools the VPN toggle grants capability for' (#28) from feat/vpn-tooling into main
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m38s
Build App / build-linux (push) Successful in 6m53s
Build App / build-windows (push) Successful in 7m39s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 1m16s
Build Container / build-container (push) Successful in 11m32s
2026-08-18 18:01:18 +00:00
shadow-testandClaude Opus 5 5dd1ab5217 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
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>
2026-08-17 17:11:17 -07:00
shadow-testandClaude Opus 5 92d64cf252 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
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>
2026-08-17 16:24:07 -07:00
4 changed files with 88 additions and 42 deletions
+16 -8
View File
@@ -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 - **`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` 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 - **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 `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 dependencies and the list cannot rot against Ubuntu 24.04's `t64` renames or a new Chromium
@@ -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 - **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 `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, 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 - **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 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 `/run` state makes it look as though it did. Note the two different mechanisms: `/run` is in the
@@ -327,12 +328,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` `/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 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. 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 - **`iptables` is baked, and picking `nftables` instead would have been wrong.** `Recommends:
a container cannot load; `Recommends: nftables | iptables` is also stripped by nftables | iptables` is stripped by `--no-install-recommends`, and `wg-quick` needs a backend for
`--no-install-recommends`, so `nftables` is baked explicitly. See the Dockerfile comment — the any `AllowedIPs = 0.0.0.0/0`. `nftables` is the tempting choice — preferred by `wg-quick`, half
short version is that shipping the backend fixes native Linux and Docker Desktop for Mac, nothing the size — but `wg-quick` picks nft *unconditionally* when present, and its nft ruleset needs
fixes Docker Desktop for Windows, and adding the routes directly with `ip route` sidesteps it on `nft_fib_ipv4`, which LinuxKit (Docker Desktop for Mac) does not build while it *does* build
all three. `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 ### Container Lifecycle
+20 -9
View File
@@ -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` 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. 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 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 `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 them up. `sudo apt install iproute2 wireguard-tools iptables` works in the meantime, but lives in
lives in the writable layer, so it is undone by a **Reset** and by a migration. 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 **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 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 — 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. 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 With the setting **off**, a client such as PIA or OpenVPN installs and its daemon starts normally,
the connection attempt **hangs until it times out** — a default container has no tun device to open 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 and no permission to add an interface or a route, and most clients report that as a generic timeout
rather than a permissions error. rather than a permissions error.
@@ -528,10 +528,20 @@ Things worth knowing:
address via the original gateway, or the tunnel's encrypted packets try to route through the 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 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. 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 - **Delete a client's key material when you tear a tunnel down.** Anything written under `/run` is
routes by firewall mark and needs `xt_CONNMARK` from the host kernel, which WSL2's does not have in the container's writable layer, and recreating or migrating the project runs `docker commit`
and a container cannot load. Split tunnels (a specific `AllowedIPs`) work fine, as does adding over it — so a WireGuard private key left there gets baked into the project's snapshot image and
the routes yourself with `ip route`. Native Linux and Docker Desktop for Mac are unaffected. 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 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 > This setting can only be changed when the container is stopped. Capabilities and devices are
> fixed when a container is created, so toggling it recreates the container on the next start. > fixed when a container is created, so toggling it recreates the container on the next start.
@@ -1291,6 +1301,7 @@ The sandbox container (Ubuntu 24.04) comes pre-installed with:
| ruff | Latest | Python linter/formatter | | ruff | Latest | Python linter/formatter |
| Rust | Stable | Rust development (via rustup) | | Rust | Stable | Rust development (via rustup) |
| Docker CLI | Latest | Container management (when spawning is enabled) | | 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 | | git | Latest | Version control |
| GitHub CLI (gh) | Latest | GitHub integration | | GitHub CLI (gh) | Latest | GitHub integration |
| AWS CLI | v2 | AWS services and Bedrock | | AWS CLI | v2 | AWS services and Bedrock |
+1 -1
View File
@@ -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-task-runner", "Scheduled task runner"),
("/usr/local/bin/triple-c-sso-refresh", "AWS SSO auto-refresh"), ("/usr/local/bin/triple-c-sso-refresh", "AWS SSO auto-refresh"),
("/opt/mission-control", "Mission Control (Flight Control)"), ("/opt/mission-control", "Mission Control (Flight Control)"),
("/usr/bin/wg", "VPN support (WireGuard tools)"), ("/usr/bin/wg", "VPN tooling for the VPN Support toggle (WireGuard)"),
]; ];
/// Headroom demanded on Docker's storage backend on top of the measured /// Headroom demanded on Docker's storage backend on top of the measured
+51 -24
View File
@@ -36,7 +36,7 @@ RUN for i in 1 2 3 4 5; do \
socat \ socat \
iproute2 \ iproute2 \
wireguard-tools \ wireguard-tools \
nftables \ iptables \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# `libnss3-tools` above provides `certutil`. Chrome/Chromium read neither # `libnss3-tools` above provides `certutil`. Chrome/Chromium read neither
@@ -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 # corporate CA, no matter what the system trust store says. entrypoint.sh
# degrades to a warning if it is ever missing. # degrades to a warning if it is ever missing.
# `iproute2`, `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 # 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 # 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 # add a route, and without `wg` no way to build the tunnel those two exist to
@@ -58,42 +58,69 @@ 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 # — silently, since a VPN that fails to come up looks exactly like one that was
# never started. # never started.
# #
# Measured against the *current base image*, not a bare ubuntu:24.04 — the base # Measured against the *current base image*, since a bare ubuntu:24.04 also
# already ships libelf1t64, so measuring on bare ubuntu over-counts by ~209 kB: # pulls libelf1t64 and netbase, which this base already has, and so over-reports
# +9 packages, 5,614 kB on amd64 (4,153 kB of that is iproute2+wireguard-tools, # by ~258 kB: **+12 packages, 7,203 kB on amd64**. The same set on arm64 is
# 1,461 kB is nftables). The same set on arm64 is 7,422 kB, measured against # ~14.4 MB — the package list is identical on both arches, the binaries are
# ubuntu:24.04 since the arm64 base is not cached here. # 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 # `wireguard-tools` declares `Recommends: nftables | iptables`, which the
# `--no-install-recommends` above strips. That is not cosmetic: `wg-quick`'s # `--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. # `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 # 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 # [#] iptables-restore -n
# /usr/bin/wg-quick: line 32: iptables-restore: command not found # /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 # `nftables` looks like the better pick — wg-quick prefers it, it is first in
# nft`, so with both installed iptables is dead weight), it is the first # that Recommends, it is half the size — and it is the wrong one. wg-quick picks
# alternative in the package's own Recommends, and it is roughly half the size. # 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` # nft add rule ... fib saddr type != local drop
# routes by fwmark and needs connection-mark tracking from the *host* kernel: # Error: Could not process rule: No such file or directory
# ^^^^^^^^^^^^^^ needs nft_fib_ipv4
# #
# Warning: Extension CONNMARK revision 0 not supported, missing kernel module? # 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.
# #
# WSL2's kernel has no `xt_CONNMARK` and containers have no /lib/modules to load # So `iptables` is chosen for being robust to that uncertainty rather than for
# one from, so on Docker Desktop for Windows `wg-quick up` on a full tunnel fails # beating nftables on any particular host. If nft_fib_ipv4 is present, wg-quick
# regardless of what is installed here. Native Linux and Docker Desktop for Mac # never reaches the iptables path and this costs 1.6 MB and nothing else; if it
# have it. Shipping the backend is what makes the difference on those two; # is absent, this is the difference between a working full tunnel and none.
# 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` # The residual gap is WSL2 before 6.6, which has neither symbol. Nothing
# never reaches for it, so it would add size and firewall surface for 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
#
# `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 # Remove default ubuntu user to free UID 1000 for host-user remapping
RUN if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu 2>/dev/null || userdel ubuntu; fi \ RUN if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu 2>/dev/null || userdel ubuntu; fi \