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.
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)
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>
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>
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.
## 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 main2026-08-14 15:30:04 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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/tunto open and noCAP_NET_ADMINto 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:CAP_NET_ADMINnet_rawbut notnet_admin, so a client can ping but never connect./dev/net/tuntunmodule backs it, rather thanmknod-ed inside.net.ipv4.conf.all.src_valid_markwg-quicksets this and cannot from inside a container (/proc/sysis read-only), so its handshakes are dropped by reverse-path filtering. Harmless for OpenVPN-based clients.Off by default and deliberately opt-in:
NET_ADMINlets 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-supportis written unconditionally,falseincluded, for the usualdocker commitreason: atruestamped 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
tunmodule, 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
npm run buildclean, no new clippy warnings.ip tuntap add dev tun0 mode tunplus address and route all succeed; without them it fails atopen: 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: 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>d260f2c7c3to65a3d4eb29Review 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>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
--deviceat create time. Verified against Docker 29.7:runc resolves devices — and validates sysctls — only when it builds the container. On a host with no
tunmodule,create_containertherefore succeeded andstart_containerfailed 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 takesvpn_support_enabled:start_containerhas a container id and no project, and nothing else in Triple-C requests a device, so an error naming/dev/net/tunis unambiguous on its own. The test now uses the daemon's verbatim message through bollard's realDisplayformat.Low — the security claim was overstated
Docker does not enable user-namespace remapping by default, so this is a real
CAP_NET_ADMINin 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 shareddocker0segment — 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
--tun=userspace-networkingmode 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.CertificateSettings.tsx:43). The tab's generic "stop the container first" chip did not tell the user what was about to happen.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_recreationcomparison follow the repo's conventions;create_containerhas 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)
browser_view_enabledandca_cert_path) and tsc excludes test files. Worth a shared fixture in a follow-up.useSaveState.ts:28spreads a possibly-stale project, so any config save can clobber a concurrent Rust-sidestatus/container_idwrite. Pre-existing; this PR neither causes nor worsens it.🤖 Generated with Claude Code