WIP: Ship a pia-vpn skill with the VPN support toggle #29

Draft
jknapp wants to merge 5 commits from feat/vpn-skill into feat/vpn-tooling
3 changed files with 109 additions and 44 deletions
Showing only changes of commit 48d0c3249a - Show all commits
+11 -3
View File
@@ -381,10 +381,18 @@ install_feature_skill() {
# Not just $_dest: when Mission Control is off nothing else creates the # Not just $_dest: when Mission Control is off nothing else creates the
# parent, so root would own it and `claude` could not add a skill there. # parent, so root would own it and `claude` could not add a skill there.
chown claude:claude /home/claude/.claude/skills chown claude:claude /home/claude/.claude/skills
# Stage then swap. Copying over the live path meant a failure (full
# volume, read-only mount) left a truncated SKILL.md and no script
# behind, root-owned, on a persisted volume — which Claude Code then
# discovers and loads.
rm -rf "$_dest.new"
cp -r "$_src" "$_dest.new" || {
rm -rf "$_dest.new"
echo "entrypoint: $_name skill install FAILED (copy from $_src); previous copy left intact"
return 1; }
chown -R claude:claude "$_dest.new"
rm -rf "$_dest" rm -rf "$_dest"
cp -r "$_src" "$_dest" || { mv "$_dest.new" "$_dest"
echo "entrypoint: $_name skill install FAILED (copy from $_src)"; return 1; }
chown -R claude:claude "$_dest"
echo "entrypoint: $_name skill installed to ~/.claude/skills/" echo "entrypoint: $_name skill installed to ~/.claude/skills/"
elif [ -e "$_dest" ] || [ -L "$_dest" ]; then elif [ -e "$_dest" ] || [ -L "$_dest" ]; then
# -e/-L rather than -d: a leftover *file* at that path must go too. # -e/-L rather than -d: a leftover *file* at that path must go too.
+21 -6
View File
@@ -9,7 +9,7 @@ Bring this container's traffic out through Private Internet Access over
WireGuard, using the API PIA documents for headless use. WireGuard, using the API PIA documents for headless use.
Run `sudo ~/.claude/skills/pia-vpn/pia-wg.sh` with `up`, `up --full`, `down` or 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 `status`. Read the rest of this page before the first `up --full`four of the
behaviours below are actively misleading if you meet them without warning, and 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 each one presents as "the VPN is fine" or "Claude is broken" rather than as
what it is. what it is.
@@ -107,7 +107,21 @@ mode: test route only (1.1.1.1 through the tunnel, nothing else)
Two different addresses there is correct and expected in test mode. If you want Two different addresses there is correct and expected in test mode. If you want
the second line to change, you want `up --full`. the second line to change, you want `up --full`.
## Trap 4: no tunnel survives a restart, and it fails open ## Trap 4: a full tunnel hides the Docker host unless the name is pinned
`host.docker.internal` is answered *only* by the resolver that `up --full`
replaces — it is not in `/etc/hosts`. Triple-C hands that name to the container
for the LiteLLM gateway, and host-side Ollama and custom endpoints default to
it, so losing the name takes the project's model backend down with it.
The nasty part is what a naive check reports. PIA's resolvers answer public
names perfectly well, so a probe of `api.anthropic.com` says everything is fine
while the Docker host has vanished. `pia-wg.sh` pins the address into
`/etc/hosts` before swapping the resolver and restores the file on teardown, and
`status` probes both names — but if you ever rewrite `resolv.conf` by hand, this
is the one that will not announce itself.
## Trap 5: no tunnel survives a restart, and it fails open
The network namespace is rebuilt every time the container starts, and nothing 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 inside reconnects anything. After a stop/start, Reset or any config change that
@@ -186,10 +200,11 @@ no DNS at all), removes exactly the routes that were added, in reverse order,
and deletes the interface. It is safe to run when nothing is up. Confirm and 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. afterwards that the public address is back to the container's own.
`up` calls it too, but only *after* every network fetch has succeeded, so a `up` calls it too, but only after the last network fetch — the key registration
failed `up` leaves an existing tunnel alone rather than tearing it down to — has succeeded, so a failed `up` leaves an existing tunnel alone rather than
report a bad password. From that point on a rollback is armed: if any step of tearing it down to report a bad password or an unreachable gateway. From that
the setup fails, the tunnel is torn down rather than left half-configured. point on a rollback is armed: if any step of the setup fails, the tunnel is torn
down rather than left half-configured.
The private key is deleted earlier still — the moment `wg set` has read it, The private key is deleted earlier still — the moment `wg set` has read it,
while the tunnel is being built. That is not housekeeping: `/run` is in the while the tunnel is being built. That is not housekeeping: `/run` is in the
+76 -34
View File
@@ -121,8 +121,14 @@ up() {
u=$(sed -n 1p "$CREDS"); p=$(sed -n 2p "$CREDS") u=$(sed -n 1p "$CREDS"); p=$(sed -n 2p "$CREDS")
[ -n "$u" ] && [ -n "$p" ] || die "$CREDS needs two lines: username, then password" [ -n "$u" ] && [ -n "$p" ] || die "$CREDS needs two lines: username, then password"
tok=$(run "PIA rejected the credentials in $CREDS, or could not be reached" \ # Via stdin, not `-u`. curl does blank the password in its own argv, but only
curl -sf -m 25 -u "$u:$p" \ # once it is running: sampling /proc/<pid>/cmdline in a tight loop caught the
# plaintext in 3 of 200 tries, in the window between exec and the overwrite.
# Small, but this is the permanent account password, and the mechanism to
# avoid it entirely is already here for the token.
tok=$(printf -- '--user "%s:%s"\n' "$u" "$p" \
| run "PIA rejected the credentials in $CREDS, or could not be reached" \
curl -sf -m 25 -K - \
https://www.privateinternetaccess.com/gtoken/generateToken | jq -r .token) https://www.privateinternetaccess.com/gtoken/generateToken | jq -r .token)
[ -n "$tok" ] && [ "$tok" != null ] || die "PIA returned no token - check the credentials in $CREDS" [ -n "$tok" ] && [ "$tok" != null ] || die "PIA returned no token - check the credentials in $CREDS"
@@ -133,31 +139,9 @@ up() {
sip=$(echo "$srv" | jq -r .ip); scn=$(echo "$srv" | jq -r .cn) sip=$(echo "$srv" | jq -r .ip); scn=$(echo "$srv" | jq -r .cn)
[ -n "$sip" ] && [ "$sip" != null ] || die "no WireGuard server for region '$REGION'" [ -n "$sip" ] && [ "$sip" != null ] || die "no WireGuard server for region '$REGION'"
# Only now tear down any previous tunnel. Doing it up front (as an earlier # The key is generated but NOT written yet -- `down` below deletes wg.priv, and
# version did) meant a failed token fetch or an unreachable server list took # the teardown has to come after every fetch that can fail.
# a *working* tunnel down with it and silently reverted the container to its priv=$(wg genkey); pub=$(printf '%s' "$priv" | wg pubkey)
# real address, while the error talked about credentials. Everything above
# this line can fail; nothing above it has touched the network stack.
#
# It also still does the job it was added for: clearing a stale resolv.conf
# backup so a second `up` cannot save PIA's own resolvers over the real ones.
down >/dev/null 2>&1 || true
# From here on the network stack is being modified, so any failure has to put
# it back rather than exit half-configured. `down` is idempotent and restores
# routes and resolv.conf exactly.
#
# EXIT rather than ERR, and a flag rather than the trap's own exit status: an
# ERR trap is not inherited by shell functions without `set -E`, so a failure
# inside add_route would not fire it, and `die` exits explicitly, which is not
# an error and would not fire it either. EXIT catches both.
SETUP_OK=0
trap '[ "$SETUP_OK" = 1 ] || { echo "pia-wg: setup failed - rolling back" >&2; down >/dev/null 2>&1; }' EXIT
# umask, not a later chmod: the file is created under the inherited 0022
# otherwise, so the key is world-readable for the moment in between.
( umask 077; priv=$(wg genkey); printf '%s' "$priv" > wg.priv )
priv=$(cat wg.priv); pub=$(printf '%s' "$priv" | wg pubkey)
# The token goes in on stdin as a curl config rather than in the argv, where # The token goes in on stdin as a curl config rather than in the argv, where
# `ps` and /proc/*/cmdline expose it to every process in the container -- # `ps` and /proc/*/cmdline expose it to every process in the container --
@@ -170,6 +154,32 @@ up() {
--cacert ca.rsa.4096.crt "https://$scn:1337/addKey") --cacert ca.rsa.4096.crt "https://$scn:1337/addKey")
[ "$(echo "$resp" | jq -r .status)" = OK ] || die "key registration failed: $resp" [ "$(echo "$resp" | jq -r .status)" = OK ] || die "key registration failed: $resp"
# Only now tear down any previous tunnel. Every network call above this line
# can fail, and an earlier version tore down first -- so a failed token fetch,
# an unreachable server list, or a refused key registration took a *working*
# tunnel with it and silently reverted the container to its real address while
# the error talked about credentials. Nothing above this line has touched the
# network stack. addKey is the most failure-prone of the three: it reaches one
# individual gateway by CN with a pinned certificate.
#
# It also still does the job it was added for: clearing a stale resolv.conf
# backup so a second `up` cannot save PIA's own resolvers over the real ones.
down >/dev/null 2>&1 || true
# From here on the network stack is being modified, so any failure has to put
# it back rather than exit half-configured.
#
# EXIT rather than ERR, and a flag rather than the trap's own exit status: an
# ERR trap is not inherited by shell functions without `set -E`, so a failure
# inside add_route would not fire it, and `die` exits explicitly, which is not
# an error and would not fire it either. EXIT catches both.
SETUP_OK=0
trap '[ "$SETUP_OK" = 1 ] || { echo "pia-wg: setup failed - rolling back" >&2; down >/dev/null 2>&1 || true; }' EXIT
# umask, not a later chmod: created under the inherited 0022 otherwise, so the
# key would be world-readable for the moment in between.
( umask 077; printf '%s' "$priv" > wg.priv )
: > "$STATE/routes" : > "$STATE/routes"
ip link add "$IFACE" type wireguard 2>/dev/null || \ ip link add "$IFACE" type wireguard 2>/dev/null || \
die "could not create a WireGuard interface." \ die "could not create a WireGuard interface." \
@@ -216,6 +226,19 @@ up() {
# PIA's resolvers live inside 10/8, so pin them back through the tunnel with # PIA's resolvers live inside 10/8, so pin them back through the tunnel with
# /32s -- longer still, so they beat the exclusion just added. # /32s -- longer still, so they beat the exclusion just added.
# `host.docker.internal` is answered only by the resolver about to be
# replaced -- it is not in /etc/hosts. Triple-C hands that name to the
# container for the LiteLLM gateway and defaults host-side Ollama and custom
# endpoints to it, so losing it takes the project's model backend with it.
# The *route* to it is already excluded above; only the name needs pinning.
# Resolve it with the old resolver and write it into /etc/hosts first.
local hdi
hdi=$(getent ahostsv4 host.docker.internal 2>/dev/null | awk '{print $1; exit}')
if [ -n "$hdi" ]; then
cp /etc/hosts "$STATE/hosts.bak"
printf '%s host.docker.internal\n' "$hdi" >> /etc/hosts
fi
cp /etc/resolv.conf "$STATE/resolv.conf.bak" cp /etc/resolv.conf "$STATE/resolv.conf.bak"
for d in $dns; do add_route "$d/32" dev "$IFACE"; done for d in $dns; do add_route "$d/32" dev "$IFACE"; done
# resolv.conf is a bind mount: write through it, never replace it. # resolv.conf is a bind mount: write through it, never replace it.
@@ -229,10 +252,16 @@ up() {
# A tunnel with no handshake still routes -- into a black hole. Without this # A tunnel with no handshake still routes -- into a black hole. Without this
# `up --full` would exit 0 having pointed all traffic *and* resolv.conf at a # `up --full` would exit 0 having pointed all traffic *and* resolv.conf at a
# peer that never answered, and `status` would print "mode: full tunnel". # peer that never answered, and `status` would print "mode: full tunnel".
local waited=0 # Demand a number, not just "different from 0". `wg show` prints nothing at
until [ "$(wg show "$IFACE" latest-handshakes | awk '{print $2; exit}')" != 0 ]; do # all when the interface has no peer, and writes to stderr when the interface
# is gone -- both leave $2 empty, and `[ "" != 0 ]` is true, so the original
# form treated a missing tunnel as a completed handshake and exited 0. `until`
# suspends both `set -e` and `pipefail`, so nothing else was going to catch it.
local waited=0 hs
until hs=$(wg show "$IFACE" latest-handshakes 2>/dev/null | awk 'NR==1{print $2}')
[[ $hs =~ ^[0-9]+$ ]] && [ "$hs" -gt 0 ]; do
waited=$((waited + 1)) waited=$((waited + 1))
[ "$waited" -lt 20 ] || die "no handshake from $REGION after 10s - rolled back" [ "$waited" -lt 40 ] || die "no handshake from $REGION after 20s"
sleep 0.5 sleep 0.5
done done
@@ -243,6 +272,11 @@ up() {
down() { down() {
[ "$(id -u)" = 0 ] || die "run with sudo" [ "$(id -u)" = 0 ] || die "run with sudo"
# Teardown must finish even if a step fails; a half-rollback is the state this
# exists to prevent. Deliberately not inherited from the caller's `set -e`.
set +e
# First, because removing the interface removes every route that points at it.
ip link del "$IFACE" 2>/dev/null
# Only restore something that actually looks like a resolver file. Restoring # Only restore something that actually looks like a resolver file. Restoring
# an empty or truncated backup leaves the container with no DNS at all, which # an empty or truncated backup leaves the container with no DNS at all, which
# is worse than leaving the current one alone. # is worse than leaving the current one alone.
@@ -254,6 +288,10 @@ down() {
fi fi
rm -f "$STATE/resolv.conf.bak" rm -f "$STATE/resolv.conf.bak"
fi fi
if [ -f "$STATE/hosts.bak" ]; then
cat "$STATE/hosts.bak" > /etc/hosts
rm -f "$STATE/hosts.bak"
fi
if [ -f "$STATE/routes" ]; then if [ -f "$STATE/routes" ]; then
# Reverse order: the specific overrides go before the ranges they sit in. # Reverse order: the specific overrides go before the ranges they sit in.
tac "$STATE/routes" | while read -r r; do tac "$STATE/routes" | while read -r r; do
@@ -261,7 +299,6 @@ down() {
done done
rm -f "$STATE/routes" rm -f "$STATE/routes"
fi fi
ip link del "$IFACE" 2>/dev/null || true
# /run is in the writable layer and `docker commit` bakes it into the # /run is in the writable layer and `docker commit` bakes it into the
# project's snapshot image, so a key left here rides that image into every # project's snapshot image, so a key left here rides that image into every
# future container. Verified: a snapshot already carried one. # future container. Verified: a snapshot already carried one.
@@ -287,10 +324,15 @@ status() {
# Resolve a name, not an IP literal. A curl to 1.1.1.1 succeeds while DNS is # 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. # completely broken, which is exactly how a dead resolver goes unnoticed.
printf 'DNS: ' printf 'DNS: '
if timeout 10 getent hosts api.anthropic.com >/dev/null 2>&1; then 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" echo "BROKEN - cannot resolve api.anthropic.com"
elif ! timeout 10 getent hosts host.docker.internal >/dev/null 2>&1; then
# PIA's resolvers answer public names happily, so probing only
# api.anthropic.com reports "ok" on a container that has just lost the
# Docker host -- and with it the LiteLLM gateway and any host-side Ollama.
echo "public ok, but host.docker.internal is UNRESOLVABLE (gateway/Ollama backends will fail)"
else
echo "ok (via $(sed -n 's/^nameserver //p' /etc/resolv.conf | tr '\n' ' '))"
fi fi
# Report the exit per mode. In test mode the probe address is itself the one # Report the exit per mode. In test mode the probe address is itself the one