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>
This commit is contained in:
2026-08-17 17:11:50 -07:00
co-authored by Claude Opus 5
parent 7a8bbcbef7
commit dcb13d23ea
8 changed files with 244 additions and 74 deletions
+14 -6
View File
@@ -343,12 +343,20 @@ container is created once by a very long function where a dropped capability is
directly avoids both, which is what the skill does. directly avoids both, which is what the skill does.
- **The `pia-vpn` skill is installed *and removed* from `VPN_SUPPORT_ENABLED`.** `container/skills/` - **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 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 `~/.claude/skills/` on every start — refreshed each time, so a fix reaches any project whose base
`rm -rf`'d first, so files dropped from a later version do not linger. The removal branch matters image has the source, and `rm -rf`'d first, so files dropped from a later version do not linger.
as much as the install: `~/.claude` is a persisted volume, so a skill left behind after the toggle The removal branch matters as much as the install: `~/.claude` is a persisted volume, so a skill
goes off would keep instructing an agent to use a capability the container no longer has. Which is left behind after the toggle goes off would keep instructing an agent to use a capability the
also why the variable is sent as `0` rather than omitted, and why it is in `RESERVED_ENV_EXACT` — container no longer has. Which is also why the variable is sent as `0` rather than omitted (see
a custom env var of that name could otherwise claim the skill without the capability behind it. `vpn_env_var`, tested), 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.
- **Both halves of that live in the base image, so neither reaches an existing project.** A
recreation builds from the project's *own snapshot*, which has no `/opt/triple-c-skills` and no
updated `entrypoint.sh`; only a migration or a Reset delivers them. The install path says so out
loud rather than returning silently, and `/opt/triple-c-skills` is in `FEATURE_PROBES` so the
migration pre-flight lists it as missing. Worth knowing before adding anything else behind an
existing toggle: the label fingerprints *the setting*, not the set of things the setting drives,
so a project already at `true` gets no recreation at all on upgrade.
### Container Lifecycle ### Container Lifecycle
+4
View File
@@ -495,6 +495,10 @@ easy to get wrong (see the DNS note below), and it is removed again when you tur
It needs your PIA credentials in `~/pia-creds`, two lines, username then password. If you use a 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. different provider, ignore it and set up your own client; nothing else depends on it.
Like the VPN tooling above, the skill ships in the container image, so a project whose container
predates it will not get one by toggling the setting — **migrate the project** and it appears; the
migration pre-flight lists it among what you would gain.
With the setting **off**, a client such as PIA or OpenVPN installs and its daemon starts normally, With the setting **off**, a client such as PIA 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 but the connection attempt **hangs until it times out** — a default container has no tun device to open
and no permission to add an interface or a route, and most clients report that as a generic timeout and no permission to add an interface or a route, and most clients report that as a generic timeout
+29 -8
View File
@@ -841,6 +841,23 @@ type VpnHostConfigParts = (
/// host-kernel module auto-loading. It is also enough to flush netfilter rules /// host-kernel module auto-loading. It is also enough to flush netfilter rules
/// inside the container, so pair it with `sandbox_mode_enabled` advisedly. /// inside the container, so pair it with `sandbox_mode_enabled` advisedly.
/// Hence opt-in, per project, rather than on for everyone. /// Hence opt-in, per project, rather than on for everyone.
/// The env var `entrypoint.sh` installs and removes the `pia-vpn` skill from.
///
/// **Emitted either way, never omitted.** `~/.claude` is a persisted volume, so
/// turning the toggle off has to actively tell entrypoint to remove a skill an
/// earlier run left there, and an absent variable cannot say that. It is also
/// what stops a `=1` baked into a snapshot by `docker commit` from outliving
/// the setting — the explicit `=0` overwrites it.
///
/// Extracted for the same reason as [`vpn_host_config`]: the emitting code sits
/// in a very long function where a dropped or inverted value is invisible, and
/// `MISSION_CONTROL_ENABLED` twenty lines above shows the failure this avoids —
/// it is pushed only when true, so a snapshot's baked `=1` survives the toggle
/// going off.
fn vpn_env_var(enabled: bool) -> String {
format!("VPN_SUPPORT_ENABLED={}", u8::from(enabled))
}
fn vpn_host_config(enabled: bool) -> VpnHostConfigParts { fn vpn_host_config(enabled: bool) -> VpnHostConfigParts {
if !enabled { if !enabled {
return (None, None, None); return (None, None, None);
@@ -1276,14 +1293,7 @@ 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 env_vars.push(vpn_env_var(project.vpn_support_enabled));
// 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.
@@ -2799,6 +2809,17 @@ 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_emitted_either_way_never_omitted() {
// The whole removal path depends on this. If `false` ever became "emit
// nothing", a project that had the toggle on would keep the skill
// forever: the container recreates from a snapshot whose baked
// VPN_SUPPORT_ENABLED=1 would then go unchallenged, and entrypoint
// would reinstall a skill for a capability the container no longer has.
assert_eq!(vpn_env_var(true), "VPN_SUPPORT_ENABLED=1");
assert_eq!(vpn_env_var(false), "VPN_SUPPORT_ENABLED=0");
}
#[test] #[test]
fn the_vpn_skill_flag_is_reserved_from_custom_env() { fn the_vpn_skill_flag_is_reserved_from_custom_env() {
// entrypoint.sh installs and removes the pia-vpn skill from this // entrypoint.sh installs and removes the pia-vpn skill from this
+1
View File
@@ -136,6 +136,7 @@ pub const FEATURE_PROBES: &[(&str, &str)] = &[
("/usr/local/bin/triple-c-sso-refresh", "AWS SSO auto-refresh"), ("/usr/local/bin/triple-c-sso-refresh", "AWS SSO auto-refresh"),
("/opt/mission-control", "Mission Control (Flight Control)"), ("/opt/mission-control", "Mission Control (Flight Control)"),
("/usr/bin/wg", "VPN tooling for the VPN Support toggle (WireGuard)"), ("/usr/bin/wg", "VPN tooling for the VPN Support toggle (WireGuard)"),
("/opt/triple-c-skills", "Bundled skills for the VPN Support toggle (PIA VPN)"),
]; ];
/// Headroom demanded on Docker's storage backend on top of the measured /// Headroom demanded on Docker's storage backend on top of the measured
+3 -1
View File
@@ -401,7 +401,9 @@ COPY mission-control /opt/mission-control
# no skill at all. Staged in /opt because ~/.claude is a volume mount: an image # 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 underneath it would be masked from the project's first start onward.
COPY skills /opt/triple-c-skills COPY skills /opt/triple-c-skills
RUN chmod +x /opt/triple-c-skills/*/*.sh # `find`, not a `*/*.sh` glob: the glob fails the build the day a skill ships
# without a script, which is a legitimate thing for a skill to do.
RUN find /opt/triple-c-skills -name '*.sh' -exec chmod +x {} +
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
+25 -6
View File
@@ -347,18 +347,37 @@ fi
# Copied on every start rather than only when absent, so a fix to a skill # 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 # reaches projects that already have the old copy. Local edits under these
# directories do not survive — treat /opt/triple-c-skills as the source. # directories do not survive — treat /opt/triple-c-skills as the source.
#
# The source lives in the *base image*, so a project whose container predates it
# recreates from its own snapshot and has no /opt/triple-c-skills to copy from.
# That case says so rather than returning silently: the toggle is on, the
# capability is there, and the skill simply never appears — which is impossible
# to work out from the outside.
install_feature_skill() { install_feature_skill() {
_name=$1 local _name="$1"
_enabled=$2 local _enabled="$2"
_dest="/home/claude/.claude/skills/$_name" local _src="/opt/triple-c-skills/$1"
local _dest="/home/claude/.claude/skills/$1"
# A blank name would make the disabled branch `rm -rf` the whole skills
# directory, Mission Control's included, under a persisted volume.
[ -n "$_name" ] || { echo "entrypoint: install_feature_skill called with no name"; return 1; }
if [ "$_enabled" = "1" ]; then if [ "$_enabled" = "1" ]; then
[ -d "/opt/triple-c-skills/$_name" ] || return 0 if [ ! -d "$_src" ]; then
echo "entrypoint: $_name skill unavailable — this container's base image predates it; migrate the project to get it"
return 0
fi
mkdir -p /home/claude/.claude/skills mkdir -p /home/claude/.claude/skills
# 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.
chown claude:claude /home/claude/.claude/skills
rm -rf "$_dest" rm -rf "$_dest"
cp -r "/opt/triple-c-skills/$_name" "$_dest" cp -r "$_src" "$_dest"
chown -R claude:claude "$_dest" chown -R claude:claude "$_dest"
echo "entrypoint: $_name skill installed to ~/.claude/skills/" echo "entrypoint: $_name skill installed to ~/.claude/skills/"
elif [ -d "$_dest" ]; then elif [ -e "$_dest" ] || [ -L "$_dest" ]; then
# -e/-L rather than -d: a leftover *file* at that path must go too.
rm -rf "$_dest" rm -rf "$_dest"
echo "entrypoint: $_name skill removed (feature disabled)" echo "entrypoint: $_name skill removed (feature disabled)"
fi fi
+45 -7
View File
@@ -65,6 +65,19 @@ 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 `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. 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 ## 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 `curl https://1.1.1.1/cdn-cgi/trace` needs no DNS, so it returns a cheerful
@@ -116,9 +129,20 @@ p1234567
your-password your-password
``` ```
Set `PIA_CREDS` to use a different path. Treat the contents as secret: never Treat the contents as secret: never print the file, never echo the values, and
print the file, never echo the values, and never include them in a commit, a never include them in a commit, a log or a message. The script reads it directly
log or a message. The script reads it directly and does not echo it. 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`:
```bash
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 ## Regions
@@ -156,10 +180,16 @@ true in test mode too, and means much less than it sounds like.
## Tearing down ## Tearing down
`down` restores `resolv.conf` from its backup and removes exactly the routes `down` restores `resolv.conf` from its backup (only if that backup still looks
that were added, in reverse order, then deletes the interface. It is safe to like a resolver file — restoring a truncated one would leave the container with
run when nothing is up. Confirm afterwards that the public address is back to no DNS at all), removes exactly the routes that were added, in reverse order,
the container's own. 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 ## What this deliberately does not do
@@ -173,3 +203,11 @@ the container's own.
cannot work headless: the daemon never accepts a client connection without cannot work headless: the daemon never accepts a client connection without
the GUI, and `piactl --help` states that connecting requires it. If you find the GUI, and `piactl --help` states that connecting requires it. If you find
one installed, it is not a working alternative to this script. 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.
+123 -46
View File
@@ -13,13 +13,20 @@
# #
# Requires the project's "VPN support" setting (Config -> Runtime) to be on. # Requires the project's "VPN support" setting (Config -> Runtime) to be on.
# #
# Settings are read from the environment, but note that sudo resets it: they
# have to be passed *through* sudo, after the word `sudo`, not before it.
#
# sudo PIA_REGION=uk_london pia-wg.sh up --full # works
# PIA_REGION=uk_london sudo pia-wg.sh up --full # silently ignored
#
# PIA_CREDS credentials file, two lines: username, then password # PIA_CREDS credentials file, two lines: username, then password
# (default ~/pia-creds; never echoed by this script) # (default /home/claude/pia-creds; never echoed by this script)
# PIA_REGION region id (default us_chicago). List them with: # PIA_REGION region id (default us_chicago). List them with:
# curl -s https://serverlist.piaservers.net/vpninfo/servers/v6 \ # curl -s https://serverlist.piaservers.net/vpninfo/servers/v6 \
# | head -1 | jq -r '.regions[].id' # | head -1 | jq -r '.regions[].id'
set -euo pipefail set -euo pipefail
# Not ~/pia-creds: under sudo, HOME is /root.
CREDS=${PIA_CREDS:-/home/claude/pia-creds} CREDS=${PIA_CREDS:-/home/claude/pia-creds}
REGION=${PIA_REGION:-us_chicago} REGION=${PIA_REGION:-us_chicago}
IFACE=pia0 IFACE=pia0
@@ -36,11 +43,28 @@ PRIVATE_NETS="10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16"
# source lines without the indentation ending up in the output. # source lines without the indentation ending up in the output.
die() { echo "pia-wg: $*" >&2; exit 1; } die() { echo "pia-wg: $*" >&2; exit 1; }
# `x=$(cmd)` is a plain assignment, so `set -e` kills the script on a non-zero
# cmd *before* any `[ -z "$x" ] || die` line can run. Every capture below
# therefore goes through `run`; without it a wrong password exits 22 with no
# output at all, which is the most likely way this is used wrongly and was the
# least explained.
#
# It takes a description rather than reporting the command it ran: one of these
# invocations carries the account password in `-u`, and an error message is
# exactly the wrong place for that to surface.
run() { local what=$1; shift; "$@" || die "$what (exit $?)"; }
preflight() { preflight() {
[ "$(id -u)" = 0 ] || die "run with sudo" [ "$(id -u)" = 0 ] || die "run with sudo"
# CAP_NET_ADMIN is bit 12. Checking it by name gives a usable error; without # 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 # it the first `ip` call fails with a bare "Operation not permitted" that
# points nowhere near the setting that actually needs changing. # points nowhere near the setting that actually needs changing.
#
# Deliberately NOT checking /dev/net/tun: kernel WireGuard is a netlink
# interface and does not use it (verified -- `ip link add type wireguard`
# succeeds with NET_ADMIN and no tun device). It is OpenVPN and userspace
# wireguard-go that need it. The real kernel dependency here is the
# `wireguard` module, which `ip link add` below reports on directly.
local caps local caps
caps=$(awk '/^CapEff:/{print $2}' /proc/self/status) caps=$(awk '/^CapEff:/{print $2}' /proc/self/status)
if [ $(( 0x$caps & 0x1000 )) -eq 0 ]; then if [ $(( 0x$caps & 0x1000 )) -eq 0 ]; then
@@ -49,51 +73,88 @@ preflight() {
"again. That recreates the container; the home and .claude volumes" \ "again. That recreates the container; the home and .claude volumes" \
"are preserved, so nothing in them is lost." "are preserved, so nothing in them is lost."
fi fi
[ -e /dev/net/tun ] || \ command -v wg >/dev/null || \
die "/dev/net/tun is missing." \ die "wireguard-tools is not installed." \
"Same fix: turn on \"VPN support\" in Config -> Runtime. If it is" \ "If this project's container was built from an older base image," \
"already on, the Docker host's kernel is missing the tun module." "migrate it onto the current one -- that is what ships \`wg\`."
command -v wg >/dev/null || die "wireguard-tools is not installed."
[ -r "$CREDS" ] || \ [ -r "$CREDS" ] || \
die "no credentials at $CREDS." \ die "no credentials at $CREDS." \
"Two lines are expected: username, then password." \ "Two lines are expected: username, then password." \
"Set PIA_CREDS to read them from somewhere else." "Set PIA_CREDS (after the word \`sudo\`) to read them elsewhere."
} }
# Record every route we add so teardown removes exactly those and nothing else. # Routes that must work. A silent failure here is the worst state this script
add_route() { ip route add $1 2>/dev/null && echo "$1" >> "$STATE/routes" || true; } # can reach: the two half-routes need no gateway and would succeed, so the
# tunnel captures everything while the exclusions that keep DNS and the Docker
# host reachable are quietly missing -- and `status` still says "full tunnel".
add_route() {
ip route add "$@" || die "could not add route '$*'. Run 'down' to undo the partial setup."
printf '%s\n' "$*" >> "$STATE/routes"
}
up() { up() {
case "${1:-}" in
""|--full) ;;
*) die "unknown option '$1' (expected --full or nothing)." \
"Refusing rather than silently giving you a test route." ;;
esac
preflight preflight
# Always start from a known state. Without this a second `up` overwrites the
# saved resolv.conf with PIA's own resolvers, so the later `down` "restores"
# those and leaves the container with no working DNS and no way back.
down >/dev/null 2>&1 || true
mkdir -p "$STATE"; cd "$STATE" 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 \ # `curl -o` creates the file before it knows the request failed, so a plain
|| die "could not fetch PIA's CA certificate" # `[ -f ]` cache check can pin a truncated cert forever -- and /run rides the
# snapshot, so "forever" outlives the container. Fetch to a temp name and
# rename only on success.
if [ ! -s ca.rsa.4096.crt ]; then
run "could not download PIA's CA certificate" \
curl -sf -m 20 -o ca.crt.part \
https://raw.githubusercontent.com/pia-foss/manual-connections/master/ca.rsa.4096.crt
[ -s ca.crt.part ] || die "PIA's CA certificate downloaded empty"
mv ca.crt.part ca.rsa.4096.crt
fi
local u p tok srv sip scn priv pub resp ep gw dns local u p tok srv sip scn priv pub resp ep gw dns
u=$(sed -n 1p "$CREDS"); p=$(sed -n 2p "$CREDS") u=$(sed -n 1p "$CREDS"); p=$(sed -n 2p "$CREDS")
tok=$(curl -sf -m 25 -u "$u:$p" \ [ -n "$u" ] && [ -n "$p" ] || die "$CREDS needs two lines: username, then password"
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 tok=$(run "PIA rejected the credentials in $CREDS, or could not be reached" \
curl -sf -m 25 -u "$u:$p" \
https://www.privateinternetaccess.com/gtoken/generateToken | jq -r .token)
[ -n "$tok" ] && [ "$tok" != null ] || die "PIA returned no token - check the credentials in $CREDS"
run "could not fetch PIA's server list" \
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) 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) 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'"
priv=$(wg genkey); pub=$(echo "$priv" | wg pubkey) # umask, not a later chmod: the file is created under the inherited 0022
printf '%s' "$priv" > wg.priv; chmod 600 wg.priv # 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
# `ps` and /proc/*/cmdline expose it to every process in the container --
# verified. It is a ~24h bearer credential for the whole PIA account.
# PIA pins its certificate to the server's common name, which is why this # 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. # 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 \ resp=$(printf -- '--data-urlencode "pt=%s"\n--data-urlencode "pubkey=%s"\n' "$tok" "$pub" \
--data-urlencode "pt=$tok" --data-urlencode "pubkey=$pub" \ | run "could not register the key with $scn" \
"https://$scn:1337/addKey") curl -sf -m 25 -G -K - --connect-to "$scn::$sip:" \
--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"
: > "$STATE/routes" : > "$STATE/routes"
ip link del "$IFACE" 2>/dev/null || true ip link add "$IFACE" type wireguard 2>/dev/null || \
ip link add "$IFACE" type wireguard die "could not create a WireGuard interface." \
"The Docker host's kernel has no 'wireguard' module."
wg set "$IFACE" private-key wg.priv \ wg set "$IFACE" private-key wg.priv \
peer "$(echo "$resp" | jq -r .server_key)" \ peer "$(echo "$resp" | jq -r .server_key)" \
endpoint "$(echo "$resp" | jq -r .server_ip):$(echo "$resp" | jq -r .server_port)" \ endpoint "$(echo "$resp" | jq -r .server_ip):$(echo "$resp" | jq -r .server_port)" \
@@ -102,36 +163,41 @@ up() {
ip link set "$IFACE" up ip link set "$IFACE" up
if [ "${1:-}" = "--full" ]; then if [ "${1:-}" = "--full" ]; then
ep=$(echo "$resp" | jq -r .server_ip)
gw=$(ip route show default | awk '{print $3; exit}')
# `default dev eth0` with no `via` yields the literal "eth0" here, which
# would make every exclusion below a malformed no-op.
[[ $gw =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || \
die "no usable default gateway to pin the tunnel against (got '${gw:-none}')"
# PIA's resolvers are required in --full. Without them the 10/8 exclusion
# below is already in place, so every lookup would go to the container's
# own resolver *outside* the tunnel -- a full tunnel leaking all its DNS,
# reported by `status` as perfectly healthy.
dns=$(echo "$resp" | jq -r '.dns_servers[]? // empty' | head -2)
[ -n "$dns" ] || die "PIA returned no DNS servers; refusing a full tunnel that would leak every lookup"
# Pin the endpoint to the pre-existing gateway first, so the tunnel's own # 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 # 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 # route with two half-routes rather than replacing it -- nothing to restore
# on teardown, and the container keeps working if this script dies midway. # on teardown, and the container keeps working if this script dies midway.
ep=$(echo "$resp" | jq -r .server_ip) add_route "$ep/32" via "$gw"
gw=$(ip route show default | awk '{print $3; exit}') add_route 0.0.0.0/1 dev "$IFACE"
add_route "$ep/32 via $gw" add_route 128.0.0.0/1 dev "$IFACE"
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 # Keep container, host and LAN traffic off the tunnel. Longer prefixes than
# the two halves above, so these win. # the two halves above, so these win.
for n in $PRIVATE_NETS; do add_route "$n via $gw"; done 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 # PIA's resolvers live inside 10/8, so pin them back through the tunnel with
# /32 -- longer still, so it beats the 10.0.0.0/8 exclusion just added. # /32s -- longer still, so they beat the exclusion just added.
# Using PIA's resolver rather than the container's keeps DNS from leaking, cp /etc/resolv.conf "$STATE/resolv.conf.bak"
# and the container's own resolver is unreachable from inside the tunnel. for d in $dns; do add_route "$d/32" dev "$IFACE"; done
dns=$(echo "$resp" | jq -r '.dns_servers[]? // empty' | head -2) # resolv.conf is a bind mount: write through it, never replace it.
if [ -n "$dns" ]; then for d in $dns; do echo "nameserver $d"; done > /etc/resolv.conf
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" echo "full tunnel: public traffic exits via PIA; private ranges stay local"
else else
add_route "1.1.1.1/32 dev $IFACE" add_route 1.1.1.1/32 dev "$IFACE"
echo "test route only: 1.1.1.1 goes via PIA, everything else unchanged" echo "test route only: 1.1.1.1 goes via PIA, everything else unchanged"
fi fi
@@ -141,8 +207,15 @@ up() {
down() { down() {
[ "$(id -u)" = 0 ] || die "run with sudo" [ "$(id -u)" = 0 ] || die "run with sudo"
# 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
# is worse than leaving the current one alone.
if [ -f "$STATE/resolv.conf.bak" ]; then if [ -f "$STATE/resolv.conf.bak" ]; then
cat "$STATE/resolv.conf.bak" > /etc/resolv.conf if grep -q '^nameserver' "$STATE/resolv.conf.bak" 2>/dev/null; then
cat "$STATE/resolv.conf.bak" > /etc/resolv.conf
else
echo "pia-wg: warning - saved resolv.conf looks empty; leaving the current one alone" >&2
fi
rm -f "$STATE/resolv.conf.bak" rm -f "$STATE/resolv.conf.bak"
fi fi
if [ -f "$STATE/routes" ]; then if [ -f "$STATE/routes" ]; then
@@ -153,6 +226,10 @@ down() {
rm -f "$STATE/routes" rm -f "$STATE/routes"
fi fi
ip link del "$IFACE" 2>/dev/null || true ip link del "$IFACE" 2>/dev/null || true
# /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
# future container. Verified: a snapshot already carried one.
rm -f "$STATE/wg.priv"
echo "tunnel down" echo "tunnel down"
} }
@@ -197,5 +274,5 @@ case "${1:-}" in
up) shift; up "${1:-}" ;; up) shift; up "${1:-}" ;;
down) down ;; down) down ;;
status) status ;; status) status ;;
*) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;; *) sed -n '2,27p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
esac esac