From 0f3973023ac1a2de41aee3ef7bd5aa173e3a8487 Mon Sep 17 00:00:00 2001 From: tommy Date: Tue, 30 Jun 2026 22:01:16 -0500 Subject: [PATCH] gluetun-pia: CA-pinned regen + CA-aware region auto-pick (fix legacy-CN break) Replaces the Go pia-wg-config (Go 1.23 rejects PIA legacy-CN no-SAN certs) with PIA's official CA-pinned curl flow. Adds CA-aware region auto-pick so ExpressVPN-CA migrated regions (e.g. us_chicago) auto-skip; region moved to us_denver. Tunnel restored + egress verified 2026-06-30. Co-Authored-By: Claude Opus 4.8 --- gluetun-pia/Dockerfile | 10 +++ gluetun-pia/README.md | 49 ++++++++++++++ gluetun-pia/regen-and-up.sh | 13 ++++ gluetun-pia/regen-entrypoint.sh | 116 ++++++++++++++++++++++++++++++++ gluetun-pia/regen-wg.sh | 8 +++ 5 files changed, 196 insertions(+) create mode 100644 gluetun-pia/Dockerfile create mode 100644 gluetun-pia/README.md create mode 100755 gluetun-pia/regen-and-up.sh create mode 100644 gluetun-pia/regen-entrypoint.sh create mode 100755 gluetun-pia/regen-wg.sh diff --git a/gluetun-pia/Dockerfile b/gluetun-pia/Dockerfile new file mode 100644 index 0000000..4b52464 --- /dev/null +++ b/gluetun-pia/Dockerfile @@ -0,0 +1,10 @@ +# PIA WireGuard peer regenerator for gluetun custom mode. +# Uses PIA's official CA-pinned curl flow (token + addKey) instead of the Go +# pia-wg-config binary, which modern-Go TLS breaks on PIA's legacy-CommonName +# (no-SAN) server certs. See regen-entrypoint.sh / memory pia-wg-regen-broken-go-legacy-cn. +FROM alpine:3.20 +RUN apk add --no-cache bash curl jq wireguard-tools ca-certificates +COPY ca.rsa.4096.crt /etc/pia/ca.rsa.4096.crt +COPY regen-entrypoint.sh /usr/local/bin/regen-entrypoint.sh +RUN chmod +x /usr/local/bin/regen-entrypoint.sh +ENTRYPOINT ["/usr/local/bin/regen-entrypoint.sh"] diff --git a/gluetun-pia/README.md b/gluetun-pia/README.md new file mode 100644 index 0000000..227b075 --- /dev/null +++ b/gluetun-pia/README.md @@ -0,0 +1,49 @@ +# gluetun PIA WireGuard peer regenerator + +Generates `wg0.conf` for gluetun **custom** mode (gluetun has no native PIA +WireGuard). sabnzbd + transmission ride gluetun's netns. Live stack runs from +`~/downloads/` on media-server (.183) — that compose is part of the docker stack, +not this repo; the regenerator source is captured here. + +## Why this exists (2026-06-30 rewrite) +The previous approach built `kylegrantlucas/pia-wg-config` (Go 1.23), which +TLS-rejects PIA's legacy-CommonName (no-SAN) server certs: +`x509: certificate relies on legacy Common Name field`. Compounding it, the old +region `us_chicago` was migrated onto Kape/ExpressVPN-CA infra whose certs don't +chain to PIA's `ca.rsa.4096.crt`. + +**Fix:** `regen-entrypoint.sh` now uses PIA's official **CA-pinned curl flow** +(`authv3/generateToken` + `addKey`, `--connect-to ::: --cacert`). OpenSSL +honors CN-fallback on no-SAN certs where Go won't. It also does **CA-aware region +auto-pick**: tries `$PIA_REGION` then a US-centric fallback list; the CA-pinned +token step is the gate, so ExpressVPN-migrated regions auto-skip (self-heals future +Kape migrations). + +## Files +- `regen-entrypoint.sh` — the regenerator (container ENTRYPOINT) +- `Dockerfile` — alpine + curl/jq/wireguard-tools; bakes PIA CA at `/etc/pia/ca.rsa.4096.crt` +- `ca.rsa.4096.crt` — PIA's public CA (from pia-foss/manual-connections) +- `regen-and-up.sh` — **recovery command**: regen peer → recreate gluetun → reconnect sab/transmission (run this on a `VpnTunnelDown` alert) +- `regen-wg.sh` — regen config only (no gluetun restart) + +## Required docker-compose wiring (in the ~/downloads stack) +```yaml + pia-wg-regen: + build: ./pia-wg + image: pia-wg-regen:v1.1.1 + container_name: pia-wg-regen + environment: + - PIA_REGION=us_denver # was us_chicago (moved to ExpressVPN CA) + volumes: + - ./.env:/pia/.env:ro # OPENVPN_USER / OPENVPN_PASSWORD (gitignored — never commit) + - ./pia-wg/config:/config # wg0.conf output (contains the private key — never commit) + restart: "no" +``` + +## Recovery +``` +cd ~/downloads/pia-wg && ./regen-and-up.sh +``` +Verify: `docker exec gluetun wget -qO- http://localhost:8000/v1/publicip/ip` +should show a PIA IP (not the home WAN). NB: gluetun `/v1/openvpn/status` reads +`stopped` under WireGuard — normal; use `/v1/publicip/ip`. diff --git a/gluetun-pia/regen-and-up.sh b/gluetun-pia/regen-and-up.sh new file mode 100755 index 0000000..dab5dac --- /dev/null +++ b/gluetun-pia/regen-and-up.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +# Self-heal recovery for a stale/dead PIA WireGuard tunnel — the one-line action to run +# when the VpnTunnelDown alert fires. Registers a fresh peer, then recreates gluetun so +# it loads the new wg0.conf, and reconnects SAB/Transmission through the restored netns. +cd "$(dirname "$0")/.." # -> ~/downloads +echo "[regen-and-up] 1/3 registering a fresh PIA WG peer..." +docker compose run --rm pia-wg-regen +echo "[regen-and-up] 2/3 recreating gluetun with the new wg0.conf..." +docker compose up -d --force-recreate --no-deps gluetun +echo "[regen-and-up] 3/3 reconnecting dependents through the restored netns..." +docker compose up -d --force-recreate sabnzbd transmission +echo "[regen-and-up] done." diff --git a/gluetun-pia/regen-entrypoint.sh b/gluetun-pia/regen-entrypoint.sh new file mode 100644 index 0000000..83ee282 --- /dev/null +++ b/gluetun-pia/regen-entrypoint.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Regenerate a FRESH PIA WireGuard peer and write wg0.conf for gluetun (custom mode), +# using PIA's official CA-pinned curl flow (token via authv3/generateToken + addKey). +# +# WHY NOT pia-wg-config: the pinned Go binary (Go 1.23) TLS-rejects PIA's legacy +# CommonName (no-SAN) server certs. OpenSSL/curl still honor CN fallback, so the same +# endpoints validate fine here. See memory: pia-wg-regen-broken-go-legacy-cn. +# +# CA-AWARE region auto-pick: PIA has migrated some regions (e.g. us_chicago) onto +# Kape/ExpressVPN-CA infra whose certs do NOT chain to PIA's ca.rsa.4096.crt. Because +# every request below is --cacert-pinned to the PIA CA, such a region fails the token +# step (curl exit 60) and is transparently skipped. We try $PIA_REGION first, then a +# US-centric PIA-native fallback list; the first region that validates + registers wins. +# +# Creds read from a read-only-mounted .env so the password never lands in this +# container's Config.Env, the compose file, or any log. + +ENV_FILE="${ENV_FILE:-/pia/.env}" +OUT="${OUT:-/config/wg0.conf}" +CA="${CA:-/etc/pia/ca.rsa.4096.crt}" +SERVERLIST="${SERVERLIST:-https://serverlist.piaservers.net/vpninfo/servers/v6}" +PREFERRED="${PIA_REGION:-us_denver}" +# fallback order if the preferred region isn't PIA-CA / is unreachable +FALLBACKS="us_denver us_atlanta us_california us_east us_seattle ca_toronto" + +log(){ echo "[regen] $*" >&2; } + +[ -f "$CA" ] || { echo "ERROR: PIA CA not found at $CA" >&2; exit 1; } +user="$(grep -E '^OPENVPN_USER=' "$ENV_FILE" | head -1 | cut -d= -f2-)" +pass="$(grep -E '^OPENVPN_PASSWORD=' "$ENV_FILE" | head -1 | cut -d= -f2-)" +if [ -z "${user:-}" ] || [ -z "${pass:-}" ]; then + echo "ERROR: OPENVPN_USER/OPENVPN_PASSWORD not found in $ENV_FILE" >&2; exit 1 +fi + +log "fetching PIA server list" +servers="$(curl -fsS --max-time 20 "$SERVERLIST" | sed -n '1p')" +[ -n "$servers" ] || { echo "ERROR: empty server list" >&2; exit 1; } + +# build ordered, de-duplicated candidate list: preferred first, then fallbacks +candidates="" +for r in "$PREFERRED" $FALLBACKS; do + case " $candidates " in *" $r "*) : ;; *) candidates="$candidates $r" ;; esac +done + +# try_region -> writes $OUT and returns 0 on success; non-zero to try next +try_region() { + local region="$1" WG_IP WG_CN META_IP META_CN + read -r WG_IP WG_CN META_IP META_CN < <( + printf '%s' "$servers" | jq -r --arg r "$region" ' + .regions[] | select(.id==$r) as $reg + | ($reg.servers.wg[0]) as $wg | ($reg.servers.meta[0]) as $meta + | "\($wg.ip) \($wg.cn) \($meta.ip) \($meta.cn)"' 2>/dev/null) + [ -n "${WG_IP:-}" ] && [ -n "${META_CN:-}" ] || { log " $region: no wg/meta server, skip"; return 1; } + + # token: CA-pinned generateToken on the META server (this is also the PIA-CA gate) + local tok_json rc=0 token tok_status + tok_json="$(curl -fsS --max-time 20 -u "$user:$pass" \ + --connect-to "${META_CN}::${META_IP}:" --cacert "$CA" \ + "https://${META_CN}/authv3/generateToken" 2>/dev/null)" || rc=$? + if [ "$rc" -ne 0 ]; then + [ "$rc" -eq 60 ] && log " $region: cert not PIA-CA (curl 60), skip" || log " $region: token curl failed (rc=$rc), skip" + return 1 + fi + token="$(printf '%s' "$tok_json" | jq -r '.token // empty')" + tok_status="$(printf '%s' "$tok_json" | jq -r '.status // empty')" + [ -n "$token" ] || { log " $region: no token (status=$tok_status), skip"; return 1; } + + # fresh keypair; private key never leaves this container + local priv pub add_json add_status server_key peer_ip server_ip server_port dns + priv="$(wg genkey)"; pub="$(printf '%s' "$priv" | wg pubkey)" + + add_json="$(curl -fsS --max-time 20 -G \ + --connect-to "${WG_CN}::${WG_IP}:" --cacert "$CA" \ + --data-urlencode "pt=${token}" --data-urlencode "pubkey=${pub}" \ + "https://${WG_CN}:1337/addKey" 2>/dev/null)" || { log " $region: addKey curl failed, skip"; return 1; } + add_status="$(printf '%s' "$add_json" | jq -r '.status // empty')" + [ "$add_status" = "OK" ] || { log " $region: addKey status=$add_status, skip"; return 1; } + + server_key="$(printf '%s' "$add_json" | jq -r '.server_key')" + peer_ip="$(printf '%s' "$add_json" | jq -r '.peer_ip')" + server_ip="$(printf '%s' "$add_json" | jq -r '.server_ip // empty')"; server_ip="${server_ip:-$WG_IP}" + server_port="$(printf '%s' "$add_json" | jq -r '.server_port // 1337')" + dns="$(printf '%s' "$add_json" | jq -r '.dns_servers[0] // empty')" + for v in server_key peer_ip dns; do + [ -n "${!v}" ] && [ "${!v}" != "null" ] || { log " $region: addKey missing $v, skip"; return 1; } + done + + local TMP; TMP="$(mktemp "${OUT}.XXXXXX")" + cat > "$TMP" <&2; rm -f "$TMP"; return 1; } + done + chmod 600 "$TMP"; mv "$TMP" "$OUT" + log "regen OK: region=$region endpoint=${server_ip}:${server_port} address=${peer_ip} (PIA-CA verified)" + return 0 +} + +for region in $candidates; do + log "trying region: $region" + if try_region "$region"; then exit 0; fi +done +echo "ERROR: no PIA-CA region succeeded (tried:$candidates)" >&2 +exit 1 diff --git a/gluetun-pia/regen-wg.sh b/gluetun-pia/regen-wg.sh new file mode 100755 index 0000000..2046be3 --- /dev/null +++ b/gluetun-pia/regen-wg.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +# Register a FRESH PIA WireGuard peer and rewrite ./pia-wg/config/wg0.conf by running +# the pinned pia-wg-regen oneshot container. Safe to re-run (always a brand-new peer). +# Note: this only regenerates the config; gluetun must be (re)started to load it +# (use regen-and-up.sh for the full recovery, or `docker compose up -d`). +cd "$(dirname "$0")/.." # -> ~/downloads (compose project dir) +exec docker compose run --rm pia-wg-regen