Files
Triple-C/container/skills/pia-vpn/SKILL.md
T
shadow-testandClaude Opus 5 dcb13d23ea Fix what review found in the skill: five real defects
Adversarial review of #29 found bugs I confirmed by reproducing each one.

**Every hand-written error message was unreachable.** `tok=$(curl ...)` is a
plain assignment, so `set -e` acts on the command substitution before the
following `|| die` can run. A wrong password produced exit 22 and no output at
all — the most likely way this gets used wrongly, and the least explained.
All four captures now go through a `run` helper that takes a *description*
rather than echoing the command, because one of them carries the account
password in `-u`.

**`up` was not idempotent, and the second run destroyed DNS.** The resolv.conf
backup was copied unconditionally, so `up --full` twice overwrote the good
backup with PIA's own resolvers; the later `down` then "restored" those and
left the container with no working DNS and no way back. `up` now runs `down`
first. Verified: two `up --full` runs, then `down`, and the backup still holds
the original 192.168.65.7.

**An empty gateway produced total connectivity loss, reported as healthy.**
`$gw` was never validated and `add_route` swallowed every failure to /dev/null.
The two half-routes need no gateway and would succeed, so the tunnel captured
everything while the exclusions keeping DNS and the Docker host reachable
silently did not exist — and `status` still printed "full tunnel". Routes are
now fatal on failure, and a via-less default (`$3` is the literal "eth0") is
rejected.

**The PIA session token was in the process arguments** — confirmed in `ps` and
/proc/*/cmdline, a ~24h bearer credential for the account readable by anything
in the container. It now goes to curl on stdin as a config. Verified: 60 polls
across a full `up`, zero sightings.

**The preflight diagnosed the wrong kernel module.** It checked /dev/net/tun
and blamed the tun module, but kernel WireGuard is a netlink interface and does
not use it — verified by creating one with NET_ADMIN and no tun device. The
check is dropped (the container could not have started without the device
anyway) and `ip link add` now reports the real dependency.

Also: a full tunnel with no DNS servers from PIA used to warn and carry on,
which is a tunnel leaking every lookup while reporting itself healthy — now
fatal. `down` validates the backup before restoring it, so a truncated one
cannot leave the container with no resolver at all. `wg.priv` is shredded on
teardown and created under umask 077, because /run rides `docker commit` into
the snapshot image. A mistyped `up --ful` is rejected instead of silently
giving a test route.

entrypoint: `install_feature_skill` gets `local`, a blank-name guard (the
disabled branch would otherwise `rm -rf` the whole skills directory under a
persisted volume), `-e`/`-L` so a leftover *file* at the destination is cleaned
up, and a chown of the parent so `claude` can still add skills of their own
when Mission Control is off. When the base image predates the skill it now says
so instead of returning silently — and `/opt/triple-c-skills` joins
FEATURE_PROBES so the migration pre-flight reports it. Docs corrected to match:
neither half reaches an existing project without a migration.

`vpn_env_var` extracted and tested, pinning the property the whole removal path
rests on — that the variable is emitted as 0 rather than omitted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 17:11:50 -07:00

9.5 KiB

name, description
name description
pia-vpn 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.

The failure has a quiet twin. Do only the first half — exclude the private ranges, leave the resolver alone — and everything works, while every DNS query travels outside the tunnel to your ISP. A VPN that leaks the full list of what you looked up is worse than one that is visibly broken, so up --full refuses to proceed if PIA does not hand back resolvers rather than carrying on without them.

The mechanism above is Docker Desktop's. On a user-defined Docker network the resolver is 127.0.0.11, which is loopback and never captured by a default route — the trap still exists there (that resolver forwards upstream from inside the container's namespace) but arrives by a different path. Check /etc/resolv.conf rather than assuming which case you are in.

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

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, and passes PIA's session token to curl on stdin rather than in the argv, where ps would expose it to everything in the container.

PIA_CREDS points somewhere else — but sudo resets the environment, so it only takes effect after the word sudo:

sudo PIA_CREDS=/path/to/creds ~/.claude/skills/pia-vpn/pia-wg.sh up   # works
PIA_CREDS=/path/to/creds sudo ~/.claude/skills/pia-vpn/pia-wg.sh up   # ignored

The second form fails silently back to the default path. Same for PIA_REGION.

Regions

Defaults to us_chicago. Override with PIA_REGION:

sudo PIA_REGION=uk_london ~/.claude/skills/pia-vpn/pia-wg.sh up --full

List the ids:

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 (only if that backup still looks like a resolver file — restoring a truncated one would leave the container with no DNS at all), removes exactly the routes that were added, in reverse order, deletes the interface, and shreds the WireGuard private key. It is safe to run when nothing is up, and up runs it first so a repeat up cannot stack state. Confirm afterwards that the public address is back to the container's own.

The key deletion is not housekeeping: /run is in the container's writable layer, so docker commit bakes whatever is there into the project's snapshot image. A key left behind rides that image into every future container.

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.
  • Not wg-quick. Its Table=auto full-tunnel mode routes by firewall mark and needs xt_CONNMARK from the host kernel, which Docker Desktop for Windows (WSL2) does not have and a container cannot load. This script adds the routes with ip route directly, which works on every host.
  • IPv4 only. The 0.0.0.0/1 + 128.0.0.0/1 pair covers v4. A container with a global IPv6 address and a v6 default route would leak all v6 traffic outside the tunnel; Triple-C's containers do not have one by default, but check ip -6 route show default before relying on this where it matters.