Compare commits

...
3 Commits
Author SHA1 Message Date
shadow-testandClaude Opus 5 428229bd5a Report which exit is which, instead of one ambiguous "public IP"
`status` probed https://1.1.1.1/cdn-cgi/trace and printed the answer as
"public IP". In test mode 1.1.1.1 is the *only* address routed into the tunnel,
so that line reported a PIA exit while every other packet left directly — a
test tunnel reading exactly like a full one.

Found on a live container: default route still via eth0, one 1.1.1.1/32 route
through pia0, and the old status line claiming a PIA public IP. This is a
plausible route to concluding the VPN is on when it is not, which is close to
the confusion this skill exists to prevent.

Status now names the mode and, in test mode, prints both exits with the real
address called out. 1.0.0.1 serves the same trace endpoint as 1.1.1.1 and is
never routed into the tunnel, so the direct exit can be probed without DNS.

Verified against all four states: no tunnel, test mode on a live tunnel that
was already up, full tunnel, and after teardown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:54:17 -07:00
shadow-testandClaude Opus 5 d0ba1e526c Ship a pia-vpn skill with the VPN support toggle
The toggle grants CAP_NET_ADMIN and /dev/net/tun and stops there, which users
reasonably read as "turn the VPN on" — the gap between the two is the reported
bug that the default network does not route through a VPN. Close it by giving
the container an agent-usable way to build the tunnel, rather than leaving
each project to rediscover it.

container/skills/ is baked to /opt/triple-c-skills and installed into
~/.claude/skills/ by entrypoint.sh from VPN_SUPPORT_ENABLED, mirroring how
Mission Control installs its own. Staged under /opt because ~/.claude is a
volume mount that would mask an image copy from first start.

Three details that are not incidental:

- The variable is sent as 0 rather than omitted when off, because ~/.claude
  persists: entrypoint has to be *told* to remove a skill left by an earlier
  run with the toggle on, and an absent variable cannot say that. A stale skill
  is worse than none, since it instructs an agent to use a capability the
  container no longer has.
- It is reserved in RESERVED_ENV_EXACT alongside MISSION_CONTROL_ENABLED, or a
  custom env var of the same name could claim the skill without the capability
  behind it. Covered by a test.
- The skill is re-copied on every start, rm -rf'd first, so fixes reach existing
  projects and files dropped from a later version do not linger.

The skill itself carries the three things that are easy to get wrong: that a
full tunnel captures the Docker resolver and takes DNS down with it, that an
IP-literal health check cannot see a dead resolver, and that no tunnel survives
a restart while /run state riding the snapshot makes it look as though one did.
It also states what it deliberately does not do — no killswitch, no autostart —
so an agent proposes those as decisions rather than improvising them.

pia-wg.sh preflights CAP_NET_ADMIN by capability bit rather than letting the
first `ip` call fail with a bare EPERM that points nowhere near the setting
that needs changing. Credentials stay in a file (~/pia-creds, PIA_CREDS to
override) rather than the environment, where docker inspect and every process
in the container would see them.

Tested: install/refresh/remove/no-op paths of install_feature_skill against the
real function; preflight with and without the capability; and a full up --full
/ down round trip, confirming DNS via PIA's resolvers, api.anthropic.com
reachable through the exit, and routes and resolv.conf restored on teardown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:51:30 -07:00
shadow-testandClaude Opus 5 00937745f7 Ship the tools the VPN toggle grants capability for
Build Container / build-container (pull_request) Successful in 11m28s
`vpn_support_enabled` hands a project CAP_NET_ADMIN and /dev/net/tun, and the
image then contains no `ip` and no `wg` — a capability with nothing able to
exercise it. Bake `iproute2` and `wireguard-tools` (~4.3 MB with deps).

They belong in the image rather than a runtime install for the reason the
Dockerfile already gives for the Playwright libraries: the writable layer is
lost on base-image migration. A hand-installed `wg` works until an upgrade and
then vanishes, which presents as a tunnel that will not come up rather than as
a missing package. One project only had `ip` at all because MariaDB pulled in
iproute2 as a transitive dependency.

`iptables` stays out. Only a desktop client's killswitch wants it, and those
clients need a GUI the container cannot provide.

Also correct three things the docs left users to discover:

- the toggle grants capability and routes nothing, which is being reported as
  the default network "not routing through the VPN automatically"
- no tunnel survives a restart, and `/run` state riding the snapshot makes it
  look as though one did while traffic goes out the real address
- a full tunnel captures the Docker resolver, which sits outside the
  container's subnet, and takes DNS down with it — Claude Code then reports a
  connection failure because it cannot resolve api.anthropic.com, and a health
  check aimed at an IP literal passes throughout

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:38:37 -07:00
7 changed files with 509 additions and 1 deletions
+20
View File
@@ -307,6 +307,26 @@ container is created once by a very long function where a dropped capability is
container and make the switch impossible to turn off. 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 - 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. 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.
- **The `pia-vpn` skill is installed *and removed* from `VPN_SUPPORT_ENABLED`.** `container/skills/`
is baked to `/opt/triple-c-skills` and `install_feature_skill()` in `entrypoint.sh` copies it into
`~/.claude/skills/` on every start — refreshed each time, so a fix reaches existing projects, and
`rm -rf`'d first, so files dropped from a later version do not linger. The removal branch matters
as much as the install: `~/.claude` is a persisted volume, so a skill left behind after the toggle
goes off would keep instructing an agent to use a capability the container no longer has. Which is
also why the variable is sent as `0` rather than omitted, and why it is in `RESERVED_ENV_EXACT` —
a custom env var of that name could otherwise claim the skill without the capability behind it.
### Container Lifecycle ### Container Lifecycle
+29 -1
View File
@@ -475,7 +475,20 @@ 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: 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` 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.
To make that second half easier, enabling this also installs a **`pia-vpn` skill** into the
container's `~/.claude/skills/`, so Claude Code can bring up a Private Internet Access tunnel over
WireGuard for you — ask it to connect the VPN and it will. The skill carries the parts that are
easy to get wrong (see the DNS note below), and it is removed again when you turn the setting off.
It needs your PIA credentials in `~/pia-creds`, two lines, username then password. If you use a
different provider, ignore it and set up your own client; nothing else depends on it.
Without it, a client such as PIA, WireGuard or OpenVPN installs and its daemon starts normally, but 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 the connection attempt **hangs until it times out** — a default container has no tun device to open
@@ -497,6 +510,21 @@ Things worth knowing:
**start**, with an error naming `/dev/net/tun` and pointing back at this setting. **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 - 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. 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 > 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.
+27
View File
@@ -233,6 +233,7 @@ const RESERVED_ENV_EXACT: &[&str] = &[
"MCP_SERVERS_JSON", "MCP_SERVERS_JSON",
"CLAUDE_CODE_SETTINGS_JSON", "CLAUDE_CODE_SETTINGS_JSON",
"MISSION_CONTROL_ENABLED", "MISSION_CONTROL_ENABLED",
"VPN_SUPPORT_ENABLED",
"TRIPLE_C_PERMISSION_MODE", "TRIPLE_C_PERMISSION_MODE",
CLAUDE_OAUTH_TOKEN_ENV, CLAUDE_OAUTH_TOKEN_ENV,
// The model-alias vars are already covered by the `ANTHROPIC_` prefix // The model-alias vars are already covered by the `ANTHROPIC_` prefix
@@ -1275,6 +1276,15 @@ pub async fn create_container(
env_vars.push("MISSION_CONTROL_ENABLED=1".to_string()); env_vars.push("MISSION_CONTROL_ENABLED=1".to_string());
} }
// Drives the pia-vpn skill install in entrypoint.sh. Sent as 0 rather than
// omitted when off, because ~/.claude is a persisted volume: entrypoint has
// to be told to *remove* a skill left there by an earlier run with the
// toggle on, and an absent variable cannot say that.
env_vars.push(format!(
"VPN_SUPPORT_ENABLED={}",
u8::from(project.vpn_support_enabled)
));
// Permission mode — read by triple-c-task-runner for scheduled (headless) // Permission mode — read by triple-c-task-runner for scheduled (headless)
// Claude Code runs. Interactive terminals get the flags directly instead. // Claude Code runs. Interactive terminals get the flags directly instead.
env_vars.push(format!( env_vars.push(format!(
@@ -2789,6 +2799,23 @@ mod tests {
assert_eq!(cap_add.unwrap(), vec!["NET_ADMIN"]); assert_eq!(cap_add.unwrap(), vec!["NET_ADMIN"]);
} }
#[test]
fn the_vpn_skill_flag_is_reserved_from_custom_env() {
// entrypoint.sh installs and removes the pia-vpn skill from this
// variable. A custom env var of the same name would let a project claim
// the skill without the capability behind it — or keep it after the
// toggle is off — so it has to be unsettable like the others.
assert!(is_reserved_env_key("VPN_SUPPORT_ENABLED"));
assert!(is_reserved_env_key("vpn_support_enabled"));
assert_eq!(
compute_env_fingerprint(&[EnvVar {
key: "VPN_SUPPORT_ENABLED".to_string(),
value: "1".to_string(),
}]),
""
);
}
/// What bollard actually hands us when a tun-less host rejects the device. /// What bollard actually hands us when a tun-less host rejects the device.
/// ///
/// Captured verbatim from Docker 29.7: `docker create` with a missing /// Captured verbatim from Docker 29.7: `docker create` with a missing
+28
View File
@@ -34,6 +34,8 @@ RUN for i in 1 2 3 4 5; do \
cron \ cron \
bubblewrap \ bubblewrap \
socat \ socat \
iproute2 \
wireguard-tools \
&& 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
@@ -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 # 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` 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 # 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 \
&& if getent group ubuntu >/dev/null 2>&1; then groupdel ubuntu 2>/dev/null || true; fi && if getent group ubuntu >/dev/null 2>&1; then groupdel ubuntu 2>/dev/null || true; fi
@@ -314,6 +333,15 @@ RUN chmod +x /usr/local/bin/triple-c-sso-refresh
COPY mission-control /opt/mission-control COPY mission-control /opt/mission-control
# Skills that ship with a Triple-C feature rather than with Mission Control.
# entrypoint.sh installs them into ~/.claude/skills/ when the feature that owns
# them is enabled, and removes them when it is not — a skill telling an agent to
# build a tunnel in a container that no longer has CAP_NET_ADMIN is worse than
# no skill at all. Staged in /opt because ~/.claude is a volume mount: an image
# copy underneath it would be masked from the project's first start onward.
COPY skills /opt/triple-c-skills
RUN chmod +x /opt/triple-c-skills/*/*.sh
COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh
COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler
+29
View File
@@ -338,6 +338,35 @@ if [ "$MISSION_CONTROL_ENABLED" = "1" ]; then
unset MISSION_CONTROL_ENABLED unset MISSION_CONTROL_ENABLED
fi fi
# ── Feature skills ──────────────────────────────────────────────────────────
# Skills owned by a Triple-C feature rather than by Mission Control. Installed
# when the feature is on, removed when it is off: ~/.claude is a persisted
# volume, so a skill left behind after its feature is disabled would keep
# telling an agent to use a capability the container no longer has.
#
# Copied on every start rather than only when absent, so a fix to a skill
# reaches projects that already have the old copy. Local edits under these
# directories do not survive — treat /opt/triple-c-skills as the source.
install_feature_skill() {
_name=$1
_enabled=$2
_dest="/home/claude/.claude/skills/$_name"
if [ "$_enabled" = "1" ]; then
[ -d "/opt/triple-c-skills/$_name" ] || return 0
mkdir -p /home/claude/.claude/skills
rm -rf "$_dest"
cp -r "/opt/triple-c-skills/$_name" "$_dest"
chown -R claude:claude "$_dest"
echo "entrypoint: $_name skill installed to ~/.claude/skills/"
elif [ -d "$_dest" ]; then
rm -rf "$_dest"
echo "entrypoint: $_name skill removed (feature disabled)"
fi
}
install_feature_skill pia-vpn "${VPN_SUPPORT_ENABLED:-0}"
unset VPN_SUPPORT_ENABLED
# ── Claude Code settings ──────────────────────────────────────────────────── # ── Claude Code settings ────────────────────────────────────────────────────
# Merge Claude Code settings into ~/.claude/settings.json (preserves existing # Merge Claude Code settings into ~/.claude/settings.json (preserves existing
# keys). Creates the file if it doesn't exist. These control TUI mode, effort # keys). Creates the file if it doesn't exist. These control TUI mode, effort
+175
View File
@@ -0,0 +1,175 @@
---
name: pia-vpn
description: Connect this container's traffic through a PIA VPN tunnel over WireGuard, or diagnose one that is not working. Use when asked to enable, route through, check, or tear down a VPN, when traffic needs to leave from a different location, or when DNS or connectivity broke after a VPN was brought up.
---
# PIA VPN
Bring this container's traffic out through Private Internet Access over
WireGuard, using the API PIA documents for headless use.
Run `sudo ~/.claude/skills/pia-vpn/pia-wg.sh` with `up`, `up --full`, `down` or
`status`. Read the rest of this page before the first `up --full` — three of the
behaviours below are actively misleading if you meet them without warning, and
each one presents as "the VPN is fine" or "Claude is broken" rather than as
what it is.
## Before anything else: what the toggle does not do
Triple-C's **VPN support** setting grants three things — `CAP_NET_ADMIN`, the
`/dev/net/tun` device, and the `net.ipv4.conf.all.src_valid_mark` sysctl — and
stops there. It starts no client, builds no tunnel and changes no route.
So "the VPN is enabled but traffic isn't going through it" is normally not a
fault. It means the capability is present and nothing has used it yet. Check
with `status` before assuming something is broken.
If the toggle is off, the script says so and names the setting. It cannot be
turned on from inside the container; the user changes it in Config → Runtime,
and it recreates the container on the next start (home and `.claude` volumes
are preserved — it is not a Reset).
## Two modes
| | routes | use when |
|---|---|---|
| `up` | only `1.1.1.1/32` | verifying the tunnel works without disturbing anything |
| `up --full` | all public traffic | you actually want traffic leaving via PIA |
Prefer `up` first. It proves the handshake, credentials and region are good
while your own connectivity is untouched, so a failure is cheap.
**`up --full` routes Claude Code's own API traffic through PIA.** If the tunnel
drops, that traffic stops until it recovers or you run `down`. Say so before
running it — the user may be mid-session, and they will experience the failure
as Claude going away, not as a VPN problem.
## Trap 1: a full tunnel takes DNS with it
The container resolves through an address on the Docker network — under Docker
Desktop, `192.168.65.7` — which sits **outside** the container's own subnet. A
default route of `0.0.0.0/0`, or the `0.0.0.0/1` + `128.0.0.0/1` pair, captures
it and posts every lookup into a tunnel that cannot carry private traffic.
Nothing resolves after that. The visible symptom is Claude Code reporting it
cannot connect, because `api.anthropic.com` no longer resolves:
```
$ curl https://api.anthropic.com/v1/messages
* Could not resolve host: api.anthropic.com (rc=6)
```
`pia-wg.sh` already handles this: it routes `10.0.0.0/8`, `172.16.0.0/12`,
`192.168.0.0/16` and `169.254.0.0/16` back via the original gateway, then pins
PIA's own resolvers through the tunnel with `/32` routes that outrank the
`10/8` exclusion. If you ever route traffic by hand, you owe both halves — the
exclusions *and* a resolver reachable from wherever you pointed the default.
## Trap 2: an IP-literal health check cannot see a dead resolver
`curl https://1.1.1.1/cdn-cgi/trace` needs no DNS, so it returns a cheerful
PIA exit address while name resolution is entirely broken. A tunnel verified
that way looks perfect and works for nothing.
`status` resolves a real name for this reason. Trust its `DNS:` line, and if
you check by hand, resolve a name rather than fetching an address.
## Trap 3: in test mode, the obvious probe is the one thing tunnelled
`up` routes `1.1.1.1` and nothing else. So checking your address by fetching
`https://1.1.1.1/cdn-cgi/trace` reports a **PIA** address — not because your
traffic is going through PIA, but because that single probe is. Everything else
still leaves directly.
This reads exactly like a working full tunnel, and it is the likeliest reason
someone concludes the VPN is on when it is not. `status` prints both exits in
test mode for this reason:
```
mode: test route only (1.1.1.1 through the tunnel, nothing else)
through the tunnel: 64.113.5.73
everything else: 172.116.197.166 <- your real address
```
Two different addresses there is correct and expected in test mode. If you want
the second line to change, you want `up --full`.
## Trap 4: no tunnel survives a restart, and it fails open
The network namespace is rebuilt every time the container starts, and nothing
inside reconnects anything. After a stop/start, Reset or any config change that
recreates the container, the interface and its routes are gone.
State under `/run/pia-wg` rides the snapshot and persists, so leftover files
make it look as though the tunnel is still configured. It is not. Traffic goes
out the real address with no error and nothing visibly different.
Never infer from `/run/pia-wg` that a tunnel is up. Run `status` — if the
handshake line is missing, there is no tunnel. Re-run `up` after every start.
## Credentials
Two lines in `~/pia-creds` — username, then password:
```
p1234567
your-password
```
Set `PIA_CREDS` to use a different path. Treat the contents as secret: never
print the file, never echo the values, and never include them in a commit, a
log or a message. The script reads it directly and does not echo it.
## Regions
Defaults to `us_chicago`. Override with `PIA_REGION`:
```bash
sudo PIA_REGION=uk_london ~/.claude/skills/pia-vpn/pia-wg.sh up --full
```
List the ids:
```bash
curl -s https://serverlist.piaservers.net/vpninfo/servers/v6 \
| head -1 | jq -r '.regions[].id'
```
## Verifying
`status` prints the handshake, DNS, and which address traffic actually leaves
from — labelled by mode, so the answer cannot be misread:
```
latest handshake: 2 seconds ago
transfer: 92 B received, 180 B sent
DNS: ok (via 10.0.0.243 10.0.0.242)
mode: full tunnel
all traffic exits: 64.113.5.244
```
All of it matters. A handshake with `DNS: BROKEN` is trap 1. `mode: test route
only` with two different addresses is trap 3, and is correct — it means the
tunnel works and you have not asked for it to carry anything yet. Report the
mode line when telling someone the VPN is on; "the public IP is a PIA one" is
true in test mode too, and means much less than it sounds like.
## Tearing down
`down` restores `resolv.conf` from its backup and removes exactly the routes
that were added, in reverse order, then deletes the interface. It is safe to
run when nothing is up. Confirm afterwards that the public address is back to
the container's own.
## What this deliberately does not do
- **No killswitch.** Blocking non-tunnel egress needs `iptables`, which is not
in the image, and would cut Claude Code's API traffic whenever the tunnel is
down. If the user needs guaranteed egress rather than convenient egress, say
so plainly rather than improvising one — it is a real design decision.
- **No autostart.** There is no service manager in the container and Triple-C
has no start hook, so nothing can re-establish the tunnel automatically.
- **Not PIA's desktop client.** `pia-daemon` and `piactl` are installable but
cannot work headless: the daemon never accepts a client connection without
the GUI, and `piactl --help` states that connecting requires it. If you find
one installed, it is not a working alternative to this script.
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env bash
# PIA over WireGuard, headless.
#
# PIA's desktop client (pia-daemon + piactl) cannot work here: its daemon never
# accepts a client connection without the GUI running, and `piactl --help` says
# as much. This talks to PIA's public API directly instead, which is the path
# PIA themselves document for headless use.
#
# sudo pia-wg.sh up tunnel up, only 1.1.1.1 routed through it (safe test)
# sudo pia-wg.sh up --full tunnel up, all *public* traffic exits via PIA
# sudo pia-wg.sh down tear down, restoring DNS and routes
# sudo pia-wg.sh status handshake, DNS and current public IP
#
# Requires the project's "VPN support" setting (Config -> Runtime) to be on.
#
# PIA_CREDS credentials file, two lines: username, then password
# (default ~/pia-creds; never echoed by this script)
# PIA_REGION region id (default us_chicago). List them with:
# curl -s https://serverlist.piaservers.net/vpninfo/servers/v6 \
# | head -1 | jq -r '.regions[].id'
set -euo pipefail
CREDS=${PIA_CREDS:-/home/claude/pia-creds}
REGION=${PIA_REGION:-us_chicago}
IFACE=pia0
STATE=/run/pia-wg
# Kept off the tunnel in --full mode. The container's DNS resolver, the Docker
# host network (host.docker.internal, any host-side Ollama), sibling containers
# and the LAN all live in here. PIA cannot route any of it, so without these
# exclusions the container reaches the public internet and nothing else --
# including, fatally, its own resolver.
PRIVATE_NETS="10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16"
# Args are joined with spaces so a long message can be written as several
# source lines without the indentation ending up in the output.
die() { echo "pia-wg: $*" >&2; exit 1; }
preflight() {
[ "$(id -u)" = 0 ] || die "run with sudo"
# CAP_NET_ADMIN is bit 12. Checking it by name gives a usable error; without
# it the first `ip` call fails with a bare "Operation not permitted" that
# points nowhere near the setting that actually needs changing.
local caps
caps=$(awk '/^CapEff:/{print $2}' /proc/self/status)
if [ $(( 0x$caps & 0x1000 )) -eq 0 ]; then
die "this container has no CAP_NET_ADMIN." \
"Turn on \"VPN support\" in Config -> Runtime and start the project" \
"again. That recreates the container; the home and .claude volumes" \
"are preserved, so nothing in them is lost."
fi
[ -e /dev/net/tun ] || \
die "/dev/net/tun is missing." \
"Same fix: turn on \"VPN support\" in Config -> Runtime. If it is" \
"already on, the Docker host's kernel is missing the tun module."
command -v wg >/dev/null || die "wireguard-tools is not installed."
[ -r "$CREDS" ] || \
die "no credentials at $CREDS." \
"Two lines are expected: username, then password." \
"Set PIA_CREDS to read them from somewhere else."
}
# Record every route we add so teardown removes exactly those and nothing else.
add_route() { ip route add $1 2>/dev/null && echo "$1" >> "$STATE/routes" || true; }
up() {
preflight
mkdir -p "$STATE"; cd "$STATE"
[ -f ca.rsa.4096.crt ] || curl -sf -m 20 -o ca.rsa.4096.crt \
https://raw.githubusercontent.com/pia-foss/manual-connections/master/ca.rsa.4096.crt \
|| die "could not fetch PIA's CA certificate"
local u p tok srv sip scn priv pub resp ep gw dns
u=$(sed -n 1p "$CREDS"); p=$(sed -n 2p "$CREDS")
tok=$(curl -sf -m 25 -u "$u:$p" \
https://www.privateinternetaccess.com/gtoken/generateToken | jq -r .token)
[ -n "$tok" ] && [ "$tok" != null ] || die "PIA authentication failed - check $CREDS"
curl -sf -m 30 https://serverlist.piaservers.net/vpninfo/servers/v6 | head -1 > servers.json
srv=$(jq -r --arg r "$REGION" '.regions[] | select(.id==$r) | .servers.wg[0]' servers.json)
sip=$(echo "$srv" | jq -r .ip); scn=$(echo "$srv" | jq -r .cn)
[ -n "$sip" ] && [ "$sip" != null ] || die "no WireGuard server for region $REGION"
priv=$(wg genkey); pub=$(echo "$priv" | wg pubkey)
printf '%s' "$priv" > wg.priv; chmod 600 wg.priv
# PIA pins its certificate to the server's common name, which is why this
# connects by CN and lets --connect-to point that name at the real address.
resp=$(curl -sf -m 25 -G --connect-to "$scn::$sip:" --cacert ca.rsa.4096.crt \
--data-urlencode "pt=$tok" --data-urlencode "pubkey=$pub" \
"https://$scn:1337/addKey")
[ "$(echo "$resp" | jq -r .status)" = OK ] || die "key registration failed: $resp"
: > "$STATE/routes"
ip link del "$IFACE" 2>/dev/null || true
ip link add "$IFACE" type wireguard
wg set "$IFACE" private-key wg.priv \
peer "$(echo "$resp" | jq -r .server_key)" \
endpoint "$(echo "$resp" | jq -r .server_ip):$(echo "$resp" | jq -r .server_port)" \
allowed-ips 0.0.0.0/0 persistent-keepalive 25
ip addr add "$(echo "$resp" | jq -r .peer_ip)/32" dev "$IFACE"
ip link set "$IFACE" up
if [ "${1:-}" = "--full" ]; then
# Pin the endpoint to the pre-existing gateway first, so the tunnel's own
# packets do not try to route through the tunnel. Then beat the default
# route with two half-routes rather than replacing it -- nothing to restore
# on teardown, and the container keeps working if this script dies midway.
ep=$(echo "$resp" | jq -r .server_ip)
gw=$(ip route show default | awk '{print $3; exit}')
add_route "$ep/32 via $gw"
add_route "0.0.0.0/1 dev $IFACE"
add_route "128.0.0.0/1 dev $IFACE"
# Keep container, host and LAN traffic off the tunnel. Longer prefixes than
# the two halves above, so these win.
for n in $PRIVATE_NETS; do add_route "$n via $gw"; done
# PIA's resolver lives inside 10/8, so pin it back through the tunnel with a
# /32 -- longer still, so it beats the 10.0.0.0/8 exclusion just added.
# Using PIA's resolver rather than the container's keeps DNS from leaking,
# and the container's own resolver is unreachable from inside the tunnel.
dns=$(echo "$resp" | jq -r '.dns_servers[]? // empty' | head -2)
if [ -n "$dns" ]; then
cp /etc/resolv.conf "$STATE/resolv.conf.bak"
for d in $dns; do add_route "$d/32 dev $IFACE"; done
# resolv.conf is a bind mount: write through it, never replace it.
for d in $dns; do echo "nameserver $d"; done > /etc/resolv.conf
else
echo "pia-wg: warning - PIA returned no DNS servers; leaving resolv.conf alone" >&2
fi
echo "full tunnel: public traffic exits via PIA; private ranges stay local"
else
add_route "1.1.1.1/32 dev $IFACE"
echo "test route only: 1.1.1.1 goes via PIA, everything else unchanged"
fi
sleep 2
status
}
down() {
[ "$(id -u)" = 0 ] || die "run with sudo"
if [ -f "$STATE/resolv.conf.bak" ]; then
cat "$STATE/resolv.conf.bak" > /etc/resolv.conf
rm -f "$STATE/resolv.conf.bak"
fi
if [ -f "$STATE/routes" ]; then
# Reverse order: the specific overrides go before the ranges they sit in.
tac "$STATE/routes" | while read -r r; do
[ -n "$r" ] && ip route del $r 2>/dev/null || true
done
rm -f "$STATE/routes"
fi
ip link del "$IFACE" 2>/dev/null || true
echo "tunnel down"
}
# Both are Cloudflare and both answer /cdn-cgi/trace over their bare address, so
# neither needs DNS. Only 1.1.1.1 is ever routed into the tunnel, which is what
# lets status tell the two exits apart.
TRACE_TUNNELLED=https://1.1.1.1/cdn-cgi/trace
TRACE_DIRECT=https://1.0.0.1/cdn-cgi/trace
exit_ip() { curl -s -m 20 "$1" | sed -n 's/^ip=//p'; }
status() {
wg show "$IFACE" 2>/dev/null | grep -E "latest handshake|transfer" || echo "no tunnel up"
# Resolve a name, not an IP literal. A curl to 1.1.1.1 succeeds while DNS is
# completely broken, which is exactly how a dead resolver goes unnoticed.
printf 'DNS: '
if timeout 10 getent hosts api.anthropic.com >/dev/null 2>&1; then
echo "ok (via $(sed -n 's/^nameserver //p' /etc/resolv.conf | tr '\n' ' '))"
else
echo "BROKEN - cannot resolve api.anthropic.com"
fi
# Report the exit per mode. In test mode the probe address is itself the one
# thing inside the tunnel, so a single "public IP" line would print a PIA
# address while every other packet leaves directly -- the exact reading that
# makes a test tunnel look like a full one.
if ip route show 0.0.0.0/1 2>/dev/null | grep -q "$IFACE"; then
echo "mode: full tunnel"
echo " all traffic exits: $(exit_ip "$TRACE_TUNNELLED")"
elif ip link show "$IFACE" >/dev/null 2>&1; then
echo "mode: test route only (1.1.1.1 through the tunnel, nothing else)"
echo " through the tunnel: $(exit_ip "$TRACE_TUNNELLED")"
echo " everything else: $(exit_ip "$TRACE_DIRECT") <- your real address"
else
echo "mode: no tunnel"
echo " all traffic exits: $(exit_ip "$TRACE_DIRECT")"
fi
}
case "${1:-}" in
up) shift; up "${1:-}" ;;
down) down ;;
status) status ;;
*) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
esac