Registers minisforum as a PIA WireGuard peer for VPN VLAN 50, with a boot-ordered kill switch (dedicated PIA-VLAN50 iptables chain + a terminal unreachable route in a dedicated routing table), multi-region addKey fallback (Hong Kong -> Taiwan -> JP Tokyo, each region's full server list, in order), and an observability-only health check. Verified live against minisforum: registration succeeds, wg-quick@pia-wg is up. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
11 KiB
pia-gateway
Makes minisforum a PIA WireGuard egress gateway for VPN VLAN 50
(10.10.50.0/24), without changing its own default route. Implements
Phase 2 of ~/repo/homelab/plan.md.
Before the first real run
- The PIA API calls in
tasks/register.yamlhave gone through three rounds of correction against real live testing (2026-08-24):serverlist.piaservers.net/vpninfo/servers/v6: works. Region values everywhere in this role are the API's id field (e.g.japan,hk,taiwan), never a display name ("JP Tokyo", "Hong Kong", "Taiwan" — those are Gluetun's labels, not something this role or PIA's API accepts).- Token acquisition originally guessed a regional-meta-server
endpoint (
/authv3/generateToken) that turned out not to be the real flow at all — it was rebuilt from PIA's actualpia-foss/manual-connectionsget_token.sh(fetched and read in full): a single fixedPOST https://www.privateinternetaccess.com/api/client/v2/token, multipartusername/password, normal system CA validation (no--cacert, novalidate_certs: false), independent of region entirely — only WireGuard server selection is regional. Not yet exercised with real credentials end to end. {wg}:1337/addKey(WireGuard key registration) — confirmed againstconnect_to_wireguard_with_token.sh(fetched and read in full): this one genuinely does need PIA's own CA bundle (files/pia-ca.crt,--cacert) and--connect-to. Originally failed over only across servers within a single fixed region (pia_region: japan) — in practice all three JP Tokyo servers failed the same way, one after another, so failover now also crosses regions: every server in Hong Kong, then every server in Taiwan, then every server in JP Tokyo (pia_region_candidatesindefaults/main.yaml), stopping at the firststatus: OK. Token passed via stdin rather than argv. Seetasks/addkey-attempt.yamland "Region fallback" below.
- Make sure the repo-root
.envhasPIA_USER/PIA_PASSWORDset (same keysmanifests/media/pia-secret.shalready reads) —ansible/ host_vars/minisforum.yamlreads them directly from there via alookup('ansible.builtin.file', ...)+ regex, deliberately not ansible-vault, so this needs no vault password at all. The lookup runs on the control node, so.envnever touches minisforum and is never committed (already gitignored). - Confirm console/recovery access to
minisforum(IPMI/physical/other out-of-band path) before applying — this role changes host routing and firewall policy. If SSH becomes unreachable, use that path. ufw status verboseandwg show(existingwg0) were not inspected live before writing this role (no passwordless sudo in discovery) — spot-check thatwg0(the separate home-VPN role) is unaffected after applying.
Region fallback
Registration tries every WireGuard server in every configured region, in
order, stopping at the first status: OK:
hk (Hong Kong):
wg server 1
wg server 2
taiwan (Taiwan):
wg server 1
wg server 2
japan (JP Tokyo):
wg server 1
wg server 2
wg server 3
The actual number and order of servers within each region always comes from the live serverlist response — never hardcoded here.
Automatic fallback (default — tries pia_region_candidates in
order):
ansible-playbook -i ansible/inventory.yaml \
ansible/playbooks/pia-gateway.yaml -K -J
Forced single region, for troubleshooting one region in isolation —
bypasses pia_region_candidates entirely, does not fall back to the
others:
ansible-playbook -i ansible/inventory.yaml \
ansible/playbooks/pia-gateway.yaml -K -J \
-e pia_region=hk
Both pia_region and every entry in pia_region_candidates are PIA API
ids — hk, taiwan, japan — not the display names Gluetun's
SERVER_REGIONS config uses ("Hong Kong", "Taiwan", "JP Tokyo"). Passing
a display name here matches nothing and fails the "region exists"
assertion before any network call is even made.
Which region/server actually ended up registered is recorded as
pia_region_used/pia_region_name_used/pia_wg_server_used, written
into pia-wg.conf's own [Peer] comment, and surfaced by the health
check (INFO configured-peer: ...) — it is whichever one answered first,
not necessarily pia_region_candidates[0].
If every region fails: journalctl (or the play's own output) has one
debug line per failed attempt, each showing region name+id, server
hostname+IP, curl exit code, a classified reason (connection timeout /
TLS-SSL failure / HTTP failure / empty response / malformed JSON /
parsed-but-not-OK status, never a generic catch-all), and sanitized
stderr — never the token, credentials, the full curl invocation, request
stdin, or a complete response body. If Hong Kong and Taiwan fail exactly
the same way Tokyo did, that's a strong signal the problem is shared
across all three (the request shape, the account credentials, or
minisforum's own network path) rather than one region being down — see
tasks/register.yaml's header comment.
Worked example (2026-08-24): exactly that happened — all 7 servers
across all 3 regions came back HTTP failure (non-2xx response), curl
exit 22, The requested URL returned error: 401, uniformly. The cause
was in addkey-attempt.yaml's own curl task, not PIA or any region:
ansible.builtin.command's stdin argument appends a trailing newline
by default (stdin_add_newline defaults to true), and
--data-urlencode pt@- does not strip it — every server was receiving
"<real token>\n" (URL-encoded, so a trailing %0A) as pt, a
different and invalid value, and correctly rejecting it. Fixed with
stdin_add_newline: false on that task. connect_to_wireguard_with_token .sh never hits this because it passes the token as a literal shell
variable, not via stdin — the stdin delivery here is this repo's own
addition (to keep the token out of ps), so it needed the flag PIA's
reference script never had to think about. Left as a worked example
because "every candidate failed identically" pointing at one shared bug
in this code, not PIA, is exactly the diagnostic story this section
promises — and it happened to be true the first time it was tested for
real.
What it does
- Registers minisforum as a PIA WireGuard peer — see "Region fallback"
above — and writes
/etc/wireguard/pia-wg.conf(Table = off— this role owns all routing for the interface, not wg-quick). - Installs
pia-killswitch.service, orderedBefore= wg-quick@pia-wg.service, that seeds a closed state at every boot (and after any UFW reload — see handlers/main.yaml): thefrom 10.10.50.0/24 lookup piarule, a terminalunreachable defaultroute in thepiatable (only whenpia-wgisn't already up — see the script's own comment for why), and a dedicatedPIA-VLAN50iptables chain jumped into by a single rule at the very top ofFORWARD, matching only source10.10.50.0/24, ending in an unconditional rate-limited log+drop. This is deliberately not a globalFORWARDdefault-policy change — minisforum runs Flannel, which needs its own broadFORWARDACCEPTs for pod traffic (source10.42.0.0/16, disjoint from VLAN 50) — so the kill switch is scoped to a chain Flannel/k3s traffic can never enter, rather than risking a chain-wide policy that was never actually proven safe against it. Technitium (10.10.40.53, minisforum's own address) gets a normal UFW input allow for 53/tcp+udp — it's locally-terminated traffic, not routed — seetasks/firewall.yaml. pia-wg.conf's ownPostUp/PreDownopen/close the narrower path on top of that closed baseline: default route viapia-wgin thepiatable, ACCEPT + established/related return inserted into thePIA-VLAN50chain (notFORWARDdirectly), and source-NAT/MASQUERADE scoped to10.10.50.0/24onpia-wgonly.- Installs an observability-only health check (
pia-gateway-healthcheck .timer, everypia_healthcheck_interval_sec) that logs interface, handshake age, rule/route, and firewall-policy state to the journal. It never remediates — see the script's header comment for why, and for what its "route-decision" check does and does not prove.
What it deliberately does not do
- Does not touch minisforum's own default route (asserted at the start of
every run —
tasks/assert-baseline.yamlfails loudly if that's already wrong). - Does not enable IPv6 forwarding or any IPv6 handling for VLAN 50.
- Does not enable MSS clamping (
pia_mss_clamp_enabled: falseby default) — flip only after Phase 5 canary MTU testing shows it's actually needed. - Does not modify
ansible/roles/wireguard(the separatewg0home-VPN server role) or its interface.
Rollback
No tag-based rollback is defined — this role doesn't have an "absent"
mode. Roll back manually on minisforum (reverses this role without
touching wg0, k3s, or the host default route):
sudo systemctl disable --now wg-quick@pia-wg pia-killswitch.service pia-gateway-healthcheck.timer
sudo rm -f /etc/systemd/system/pia-killswitch.service \
/etc/systemd/system/pia-gateway-healthcheck.service \
/etc/systemd/system/pia-gateway-healthcheck.timer
sudo systemctl daemon-reload
sudo ip rule del from 10.10.50.0/24 table pia priority 100
sudo ip route flush table pia
sudo sed -i '/^[0-9]\+\s\+pia$/d' /etc/iproute2/rt_tables
sudo iptables -D FORWARD -s 10.10.50.0/24 -j PIA-VLAN50
sudo iptables -F PIA-VLAN50
sudo iptables -X PIA-VLAN50
sudo ufw delete allow from 10.10.50.0/24 to 10.10.40.53 port 53 proto tcp
sudo ufw delete allow from 10.10.50.0/24 to 10.10.40.53 port 53 proto udp
sudo rm -f /etc/wireguard/pia-wg.conf /etc/wireguard/pia-wg.key /etc/wireguard/pia-wg.key.new /etc/wireguard/pia-ca.crt
Inspection commands to confirm rollback actually took (read-only):
sudo iptables -S FORWARD | grep PIA-VLAN50 # expect: no output
sudo iptables -L PIA-VLAN50 # expect: "iptables: No chain/target/match by that name"
sudo ufw status verbose | grep 10.10.50 # expect: no output
ip rule show | grep pia # expect: no output
ip route show table pia # expect: empty/error (table gone)
Does not remove pia-credentials/pia-credentials-sealed.yaml (the K8s
Secret used by the Gluetun sidecars) — that's a separate, unrelated
secret and rollback path. Does not touch ansible/roles/common's own
UFW rules (Flannel pod-to-pod, pod-to-Technitium) — this role never
modified those.