Let a project's container run a VPN client #27

Merged
jknapp merged 2 commits from feat/vpn-support into main 2026-08-14 15:30:04 +00:00
Owner

A VPN client installed in a container today starts, runs, and then hangs until its connection times out. Nothing reports an error, which is what makes it hard to diagnose: a default container has no /dev/net/tun to open and no CAP_NET_ADMIN to add an interface or a route with, and clients surface that as a generic timeout rather than a permissions failure.

This adds an opt-in per-project VPN support switch in Config → Runtime.

What it grants

The three pieces are useless individually, which is why vpn_host_config() defines the set in one place and the tests assert all of it:

Piece Why it is needed
CAP_NET_ADMIN Docker's default bounding set has net_raw but not net_admin, so a client can ping but never connect.
/dev/net/tun Absent from a default container — nothing to open even with the capability. Passed through from the host so the kernel's tun module backs it, rather than mknod-ed inside.
net.ipv4.conf.all.src_valid_mark WireGuard's wg-quick sets this and cannot from inside a container (/proc/sys is read-only), so its handshakes are dropped by reverse-path filtering. Harmless for OpenVPN-based clients.

Off by default and deliberately opt-in: NET_ADMIN lets anything in the container reconfigure that container's network stack. It is namespaced — no authority over the host's interfaces or over any other container.

Recreation semantics

Capabilities and devices are fixed when a container is created, so this is container state and takes the label-and-compare treatment per CLAUDE.md. triple-c.vpn-support is written unconditionally, false included, for the usual docker commit reason: a true stamped once would ride the snapshot image into every future container and make the switch impossible to turn back off.

A missing label reads as false, and off is byte-identical to a container created before this existed, so no existing project is churned. Recreation commits a snapshot first, so a client already installed in the container survives turning the switch on.

Failure mode

Requesting the device fails at creation when the Docker host's kernel has no tun module, which would otherwise surface as a project that simply refuses to start with nothing pointing back at the switch. explain_create_failure() rewrites that one error to name the switch and the Docker-Desktop-VM-versus-your-machine distinction, and leaves every other failure untouched.

Testing

  • 5 new Rust unit tests; 282 pass total, 354 frontend tests pass, npm run build clean, no new clippy warnings.
  • Verified the host config against the daemon directly: with the three settings, ip tuntap add dev tun0 mode tun plus address and route all succeed; without them it fails at open: No such file or directory.

🤖 Generated with Claude Code

A VPN client installed in a container today starts, runs, and then hangs until its connection times out. Nothing reports an error, which is what makes it hard to diagnose: a default container has no `/dev/net/tun` to open and no `CAP_NET_ADMIN` to add an interface or a route with, and clients surface that as a generic timeout rather than a permissions failure. This adds an opt-in per-project **VPN support** switch in Config → Runtime. ## What it grants The three pieces are useless individually, which is why `vpn_host_config()` defines the set in one place and the tests assert all of it: | Piece | Why it is needed | | --- | --- | | `CAP_NET_ADMIN` | Docker's default bounding set has `net_raw` but not `net_admin`, so a client can ping but never connect. | | `/dev/net/tun` | Absent from a default container — nothing to open even with the capability. Passed through from the host so the kernel's `tun` module backs it, rather than `mknod`-ed inside. | | `net.ipv4.conf.all.src_valid_mark` | WireGuard's `wg-quick` sets this and cannot from inside a container (`/proc/sys` is read-only), so its handshakes are dropped by reverse-path filtering. Harmless for OpenVPN-based clients. | Off by default and deliberately opt-in: `NET_ADMIN` lets anything in the container reconfigure that container's network stack. It is namespaced — no authority over the host's interfaces or over any other container. ## Recreation semantics Capabilities and devices are fixed when a container is created, so this is container state and takes the label-and-compare treatment per CLAUDE.md. `triple-c.vpn-support` is written **unconditionally, `false` included**, for the usual `docker commit` reason: a `true` stamped once would ride the snapshot image into every future container and make the switch impossible to turn back off. A missing label reads as `false`, and off is byte-identical to a container created before this existed, so **no existing project is churned**. Recreation commits a snapshot first, so a client already installed in the container survives turning the switch on. ## Failure mode Requesting the device fails at *creation* when the Docker host's kernel has no `tun` module, which would otherwise surface as a project that simply refuses to start with nothing pointing back at the switch. `explain_create_failure()` rewrites that one error to name the switch and the Docker-Desktop-VM-versus-your-machine distinction, and leaves every other failure untouched. ## Testing - 5 new Rust unit tests; 282 pass total, 354 frontend tests pass, `npm run build` clean, no new clippy warnings. - Verified the host config against the daemon directly: with the three settings, `ip tuntap add dev tun0 mode tun` plus address and route all succeed; without them it fails at `open: No such file or directory`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
jknapp added 1 commit 2026-08-14 15:06:46 +00:00
Let a project's container run a VPN client
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m39s
Build App (Preview) / build-linux (pull_request) Successful in 7m10s
Build App (Preview) / build-windows (pull_request) Successful in 6m33s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
65a3d4eb29
A VPN client installed in a container today starts, runs, and then hangs
until its connection times out. Nothing reports an error: a default
container has no /dev/net/tun to open and no CAP_NET_ADMIN to add an
interface or a route with, and clients surface that as a generic timeout
rather than a permissions failure.

Add an opt-in per-project "VPN support" switch granting the three things
a tunnel needs. They are useless individually, which is why
vpn_host_config() defines the set in one place and the tests assert all
of it:

  * CAP_NET_ADMIN — Docker's default bounding set has net_raw but not
    net_admin, so a client can ping but never connect.
  * /dev/net/tun — passed through from the host so the kernel's tun
    module backs it, rather than mknod-ed inside.
  * net.ipv4.conf.all.src_valid_mark — WireGuard's wg-quick sets this and
    cannot from inside a container, /proc/sys being read-only, so its
    handshakes are dropped by reverse-path filtering.

Off by default and deliberately opt-in: NET_ADMIN lets anything in the
container reconfigure that container's network stack. It is namespaced —
no authority over the host's interfaces or any other container.

Capabilities and devices are fixed when a container is created, so this
is container state and takes the label-and-compare treatment.
triple-c.vpn-support is written unconditionally, false included, for the
usual docker commit reason: a true stamped once would ride the snapshot
image into every future container and make the switch impossible to turn
back off. A missing label reads as false and off is byte-identical to
today, so no existing project is churned.

Requesting the device fails at creation when the host kernel has no tun
module, which would otherwise surface as a project that simply refuses to
start. explain_create_failure() rewrites that one error to name the
switch and the Docker-Desktop-VM-versus-your-machine distinction, and
leaves every other failure untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jknapp force-pushed feat/vpn-support from d260f2c7c3 to 65a3d4eb29 2026-08-14 15:06:46 +00:00 Compare
jknapp added 1 commit 2026-08-14 15:15:10 +00:00
Explain a missing tun device where the failure actually happens
Build App (Preview) / compute-version (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m37s
Build App (Preview) / build-linux (pull_request) Successful in 5m30s
Build App (Preview) / build-windows (pull_request) Successful in 5m55s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
2b35aa8c16
Review caught that the device guard was wired to the wrong call. The
daemon does not resolve `--device` at create: verified against Docker
29.7, `docker create --device /dev/does-not-exist` succeeds and prints an
id, and runc only resolves the device — and validates sysctls — when it
builds the container. So on a host with no tun module the create returns
fine and `start` fails, which means the explanation never ran and the
user saw the raw daemon string naming a path they would go looking for on
the wrong machine. The unit tests fed the create-side string straight in,
so they confirmed a function no real failure could reach.

Move the guard onto `start_container`, covering create as well in case a
future daemon checks earlier. It no longer takes `vpn_support_enabled` —
`start_container` has a container id and no project, and nothing else in
Triple-C ever requests a device, so an error naming /dev/net/tun is
unambiguous on its own. The test now uses the daemon's verbatim message
via bollard's real Display format.

Also from review:

  * Soften the security claim. Docker does not enable user-namespace
    remapping by default, so this is a real CAP_NET_ADMIN in the initial
    user namespace with only the network namespace confining it. It
    cannot touch host interfaces, but "confers no authority outside the
    container" was too strong: within its namespace it can set
    promiscuous mode and add addresses, routes and NAT on the shared
    docker0 segment, which puts sibling containers — the LiteLLM gateway
    among them — within ARP-spoofing reach, and it can flush netfilter
    rules sandbox mode may rely on. Said plainly in the code, CLAUDE.md
    and HOW-TO-USE.
  * Drop Tailscale from the list of clients needing this. Its
    --tun=userspace-networking mode needs neither the capability nor the
    device, and listing it invites granting NET_ADMIN for nothing.
  * Say in the toggle's own hint that changing it recreates the
    container, matching how every other recreation-triggering setting is
    labelled. The tab's generic "stop the container first" chip does not
    tell the user what is about to happen.
  * Add RuntimeSection tests: saves on, saves off explicitly rather than
    dropping the key, reflects state, is disabled while running, and
    carries the recreation warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Owner

Review round applied

Two agent reviews (Rust correctness/security, frontend/docs/integration). One high-severity bug found and fixed, plus four smaller corrections. Second commit pushed.

High — the device guard was wired to the call that never fails

The daemon does not resolve --device at create time. Verified against Docker 29.7:

$ docker create --device /dev/net/nonexistent-xyz:...:rwm ubuntu:24.04 true
7c88f1aa19cf...                                    # succeeds
$ docker start 7c88f1aa19cf
Error response from daemon: error gathering device information while
adding custom device "/dev/net/nonexistent-xyz": no such file or directory

runc resolves devices — and validates sysctls — only when it builds the container. On a host with no tun module, create_container therefore succeeded and start_container failed with the raw daemon string, naming a path the user would look for on the wrong machine. The explanation never ran, and the unit tests fed the create-side string straight into it, confirming a function no real failure could reach.

Fixed by moving the guard onto start_container (still covering create, in case a future daemon checks earlier). It no longer takes vpn_support_enabled: start_container has a container id and no project, and nothing else in Triple-C requests a device, so an error naming /dev/net/tun is unambiguous on its own. The test now uses the daemon's verbatim message through bollard's real Display format.

Low — the security claim was overstated

Docker does not enable user-namespace remapping by default, so this is a real CAP_NET_ADMIN in the initial user namespace, with only the network namespace confining it. "Confers no authority outside the container" was too strong. It cannot touch host interfaces, but within its namespace it can set promiscuous mode and add arbitrary addresses, routes and NAT on the shared docker0 segment — putting sibling containers, the LiteLLM gateway among them, within ARP-spoofing reach — and it can flush netfilter rules that sandbox mode may rely on. Now stated plainly in the code, CLAUDE.md and HOW-TO-USE.

Also fixed

  • Tailscale dropped from the client list. Its --tun=userspace-networking mode needs neither the capability nor the device; listing it invited granting NET_ADMIN for nothing. HOW-TO-USE now calls it out as the explicit exception.
  • The toggle hint now says it recreates the container, matching how every other recreation-triggering setting is labelled (CertificateSettings.tsx:43). The tab's generic "stop the container first" chip did not tell the user what was about to happen.
  • Added RuntimeSection.test.tsx — saves on, saves off explicitly rather than dropping the key, reflects state, disabled while running, recreation warning present.

Confirmed clean by review

Label handling and the container_needs_recreation comparison follow the repo's conventions; create_container has a single caller, so migration, explicit rollback and auto-rollback all preserve the capabilities, device, sysctl and label; the sysctl is safe under the bridge network mode the app always uses; serde round-trips from a legacy projects.json without loss; frontend types match the Rust struct across IPC and the save path drops nothing.

283 Rust tests, 359 frontend tests, build clean, no new clippy warnings.

Not addressed (out of scope)

  • Five frontend test fixtures omit the new field — they were already stale before this PR (also missing browser_view_enabled and ca_cert_path) and tsc excludes test files. Worth a shared fixture in a follow-up.
  • useSaveState.ts:28 spreads a possibly-stale project, so any config save can clobber a concurrent Rust-side status/container_id write. Pre-existing; this PR neither causes nor worsens it.

🤖 Generated with Claude Code

## Review round applied Two agent reviews (Rust correctness/security, frontend/docs/integration). One high-severity bug found and fixed, plus four smaller corrections. Second commit pushed. ### High — the device guard was wired to the call that never fails The daemon does not resolve `--device` at create time. Verified against Docker 29.7: ``` $ docker create --device /dev/net/nonexistent-xyz:...:rwm ubuntu:24.04 true 7c88f1aa19cf... # succeeds $ docker start 7c88f1aa19cf Error response from daemon: error gathering device information while adding custom device "/dev/net/nonexistent-xyz": no such file or directory ``` runc resolves devices — and validates sysctls — only when it builds the container. On a host with no `tun` module, `create_container` therefore succeeded and `start_container` failed with the raw daemon string, naming a path the user would look for on the wrong machine. The explanation never ran, and the unit tests fed the create-side string straight into it, confirming a function no real failure could reach. Fixed by moving the guard onto `start_container` (still covering create, in case a future daemon checks earlier). It no longer takes `vpn_support_enabled`: `start_container` has a container id and no project, and nothing else in Triple-C requests a device, so an error naming `/dev/net/tun` is unambiguous on its own. The test now uses the daemon's verbatim message through bollard's real `Display` format. ### Low — the security claim was overstated Docker does not enable user-namespace remapping by default, so this is a real `CAP_NET_ADMIN` in the *initial* user namespace, with only the network namespace confining it. "Confers no authority outside the container" was too strong. It cannot touch host interfaces, but within its namespace it can set promiscuous mode and add arbitrary addresses, routes and NAT on the shared `docker0` segment — putting sibling containers, the LiteLLM gateway among them, within ARP-spoofing reach — and it can flush netfilter rules that sandbox mode may rely on. Now stated plainly in the code, CLAUDE.md and HOW-TO-USE. ### Also fixed - **Tailscale dropped from the client list.** Its `--tun=userspace-networking` mode needs neither the capability nor the device; listing it invited granting NET_ADMIN for nothing. HOW-TO-USE now calls it out as the explicit exception. - **The toggle hint now says it recreates the container**, matching how every other recreation-triggering setting is labelled (`CertificateSettings.tsx:43`). The tab's generic "stop the container first" chip did not tell the user what was about to happen. - **Added `RuntimeSection.test.tsx`** — saves on, saves off explicitly rather than dropping the key, reflects state, disabled while running, recreation warning present. ### Confirmed clean by review Label handling and the `container_needs_recreation` comparison follow the repo's conventions; `create_container` has a single caller, so migration, explicit rollback and auto-rollback all preserve the capabilities, device, sysctl and label; the sysctl is safe under the bridge network mode the app always uses; serde round-trips from a legacy projects.json without loss; frontend types match the Rust struct across IPC and the save path drops nothing. 283 Rust tests, 359 frontend tests, build clean, no new clippy warnings. ### Not addressed (out of scope) - Five frontend test fixtures omit the new field — they were already stale before this PR (also missing `browser_view_enabled` and `ca_cert_path`) and tsc excludes test files. Worth a shared fixture in a follow-up. - `useSaveState.ts:28` spreads a possibly-stale project, so any config save can clobber a concurrent Rust-side `status`/`container_id` write. Pre-existing; this PR neither causes nor worsens it. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
jknapp merged commit 01e72e4785 into main 2026-08-14 15:30:04 +00:00
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#27