393 lines
20 KiB
YAML

---
# Part of role: pia-gateway
# Included by: tasks/main.yaml (with no_log: true — this file handles the
# PIA account password, the derived WireGuard private key, and the
# short-lived PIA auth token)
# Description: Registers minisforum as a PIA WireGuard peer and writes
# {{ pia_wg_config_dir }}/{{ pia_wg_interface }}.conf. Skipped once that
# file exists, unless pia_force_reregister or pia_force_key_rotation is
# set — see defaults/main.yaml for exactly what each does.
#
# Status as of 2026-08-24 (third pass — multi-region fallback added after
# all three JP Tokyo servers were observed failing live, one after
# another, against the single-region version of this file):
# - Token acquisition (`Request a PIA auth token` below) was completely
# rebuilt after live testing against the *actual* PIA API showed the
# first version's endpoint (a regional meta server's
# /authv3/generateToken, with PIA's private CA) doesn't exist as
# documented — it's unreachable/wrong. The correct, current flow
# (confirmed against PIA's own pia-foss/manual-connections
# get_token.sh, fetched and read in full, not from memory) is a
# single fixed POST to www.privateinternetaccess.com — a normal
# public domain with a normal publicly-trusted cert — with
# username/password as multipart form fields. It does not depend on
# pia_region/pia_region_candidates at all; only WireGuard server
# SELECTION does.
# - addKey (WireGuard key registration) was already close to correct on
# the first pass and is confirmed against
# connect_to_wireguard_with_token.sh, fetched and read in full: a
# regional server IP with SNI pinned to its hostname via
# --connect-to, validated against PIA's own CA bundle (still needed
# here — only the token endpoint uses the system CA store), token via
# stdin not argv. Failing over across only the servers *within* one
# region turned out not to be enough in practice — all three JP Tokyo
# servers failed the same way, one after another. This version tries
# every server in every configured region, in order (pia_region_
# candidates, or a single pia_region override — see defaults/
# main.yaml), not just every server within a single fixed region. The
# underlying request shape sent to each server is unchanged from the
# previous pass (still connect_to_wireguard_with_token.sh's flow) —
# if Hong Kong and Taiwan fail identically to how Tokyo did, that
# points at something shared across every region (the request itself,
# credentials, or minisforum's own network path), not at Tokyo
# specifically, and the per-attempt diagnostics below are what
# distinguishes those cases from three-independent-regions-all-down.
# - serverlist fetch + parse: unchanged, exercised live and working.
- name: Determine the effective list of PIA regions to try
ansible.builtin.set_fact:
pia_effective_regions: "{{ [pia_region] if (pia_region is not none and (pia_region | trim | length) > 0) else pia_region_candidates }}"
# A single -e pia_region=<id> override takes over completely (for
# troubleshooting one region in isolation) rather than being prepended
# to the candidate list — mixing the two would make "did it use my
# override or fall through to the list" ambiguous.
- name: Assert the effective region list is usable
ansible.builtin.assert:
that:
- pia_effective_regions is defined
- pia_effective_regions | length > 0
- pia_effective_regions | select('none') | list | length == 0
- pia_effective_regions | map('string') | map('trim') | select('equalto', '') | list | length == 0
fail_msg: >-
pia_region_candidates (or a single -e pia_region override) resolved
to an empty or invalid region list:
{{ pia_effective_regions | default('undefined') }}. Set
pia_region_candidates in defaults/main.yaml to a nonempty list of
PIA region ids (not display names), or pass -e pia_region=<id> for
a single region.
- name: Check whether a PIA WireGuard config already exists
ansible.builtin.stat:
path: "{{ pia_wg_config_dir }}/{{ pia_wg_interface }}.conf"
register: pia_wg_conf_stat
- name: PIA registration
# | bool on both flags: -e pia_force_reregister=true (the plain
# key=value CLI form this role's own README documents) sets the var as
# a STRING "true", not a real boolean — recent ansible-core versions
# reject using a string directly in a `when:` boolean expression
# ("Conditional result (True) was derived from value of type 'str'"),
# confirmed live (2026-08-25) against the exact documented invocation.
# | bool coerces either a real bool (the defaults/main.yaml default,
# unaffected either way) or a "true"/"false" string (the CLI-override
# case) into an actual boolean, so both invocation styles work.
when: (pia_force_reregister | bool) or (pia_force_key_rotation | bool) or not pia_wg_conf_stat.stat.exists
block:
- name: Ensure WireGuard config directory exists
ansible.builtin.file:
path: "{{ pia_wg_config_dir }}"
state: directory
mode: "0700"
owner: root
group: root
- name: Determine which private key path this run will use
ansible.builtin.set_fact:
pia_active_key_path: "{{ (pia_wg_config_dir + '/' + pia_wg_interface + '.key.new') if (pia_force_key_rotation | bool) else (pia_wg_config_dir + '/' + pia_wg_interface + '.key') }}"
# Rotation stages the new key at a separate *.key.new path and only
# promotes it (see the "Promote the rotated key" block at the end
# of this file) after PIA has accepted it AND the new config has
# been written successfully — the previous key/config are never
# touched until both of those have actually succeeded, so a failed
# rotation leaves the working gateway exactly as it was rather
# than half-migrated.
- name: Generate minisforum's PIA WireGuard private key for this run
ansible.builtin.shell: |
set -euo pipefail
umask 077
wg genkey > {{ pia_active_key_path }}
args:
creates: "{{ pia_active_key_path }}"
- name: Set permissions on the active private key
ansible.builtin.file:
path: "{{ pia_active_key_path }}"
mode: "0600"
owner: root
group: root
- name: Read the active private key
ansible.builtin.slurp:
src: "{{ pia_active_key_path }}"
register: pia_private_key_raw
- name: Derive the public key
ansible.builtin.command: wg pubkey
args:
stdin: "{{ pia_private_key_raw.content | b64decode | trim }}"
register: pia_public_key
changed_when: false
- name: Fetch PIA's region/server list
ansible.builtin.uri:
url: https://serverlist.piaservers.net/vpninfo/servers/v6
return_content: true
register: pia_serverlist_raw
# The response body is one line of minified JSON followed by a
# detached signature block. Take just the first line (matching the
# standard `head -1` extraction used elsewhere for this endpoint) —
# do NOT split on a blank-line separator: that assumption was wrong
# in practice (observed failing against the live response) and
# silently handed the whole blob, signature included, to from_json.
- name: Parse the full region list
# Deliberately one physical line, not a folded (>-) block: an
# earlier version split a similar expression across two lines with
# the second more indented than the first, which YAML's folding
# rule ("more-indented lines are not folded") turns into a literal
# embedded newline instead of a space — that corrupted from_json's
# view of the string in practice (confirmed against the live
# endpoint) even though the newline sits outside any bracket. Keep
# this on one line if it's ever touched again.
ansible.builtin.set_fact:
pia_all_regions: "{{ (pia_serverlist_raw.content.split('\n')[0] | trim | from_json)['regions'] }}"
- name: Look up each requested region's metadata, in order
ansible.builtin.set_fact:
pia_region_lookup: "{{ (pia_region_lookup | default([])) + [pia_match] }}"
loop: "{{ pia_effective_regions }}"
loop_control:
loop_var: pia_region_id
vars:
pia_match_list: "{{ pia_all_regions | selectattr('id', 'equalto', pia_region_id) | list }}"
pia_match: "{{ pia_match_list[0] if (pia_match_list | length > 0) else {'id': pia_region_id, 'name': None, 'servers': {'wg': []}} }}"
# Builds pia_region_lookup as one entry per requested region, in
# the SAME order as pia_effective_regions (not the API's own region
# order) — that order is what makes "try hk, then taiwan, then
# japan" actually mean that. A region that doesn't exist in the
# live serverlist gets a sentinel entry (name: null, no wg
# servers) rather than being silently skipped, so it can be
# reported precisely below instead of just quietly trying one
# fewer region than requested.
- name: Identify any requested regions that don't exist in the current serverlist
ansible.builtin.set_fact:
pia_missing_regions: "{{ pia_region_lookup | selectattr('name', 'none') | map(attribute='id') | list }}"
- name: Identify any requested regions with no WireGuard servers
ansible.builtin.set_fact:
pia_empty_wg_regions: "{{ pia_region_lookup | rejectattr('name', 'none') | rejectattr('servers.wg') | map(attribute='id') | list }}"
- name: Assert every requested region exists and offers WireGuard
ansible.builtin.assert:
that:
- pia_missing_regions | length == 0
- pia_empty_wg_regions | length == 0
fail_msg: >-
One or more requested PIA regions are unusable — check each id
against the 'id' field of
https://serverlist.piaservers.net/vpninfo/servers/v6 (these are
API ids, not Gluetun's display names like "JP Tokyo"). Not
found in the current serverlist at all:
{{ pia_missing_regions | default([]) }}. Found but offer no
WireGuard servers right now:
{{ pia_empty_wg_regions | default([]) }}. Requested (in order):
{{ pia_effective_regions }}. Not silently substituting a
different region — fix pia_region_candidates/pia_region and
rerun.
- name: Build the ordered, flattened list of region+server candidates
ansible.builtin.set_fact:
pia_candidates: "{{ (pia_candidates | default([])) + [{'region_id': pia_pair.0.id, 'region_name': pia_pair.0.name, 'cn': pia_pair.1.cn, 'ip': pia_pair.1.ip}] }}"
loop: "{{ pia_region_lookup | subelements('servers.wg') }}"
loop_control:
loop_var: pia_pair
# subelements preserves both list orders: outer (region, matching
# pia_effective_regions) and inner (server, matching whatever order
# the live API returned for that region — never hardcoded here).
# Each flattened candidate keeps region id, region display name,
# WireGuard hostname, and WireGuard IP together, so
# addkey-attempt.yaml and diagnostics never need to re-look-up the
# parent region for a given server.
- name: Assert at least one candidate server was found
ansible.builtin.assert:
that:
- pia_candidates | length > 0
fail_msg: >-
No WireGuard servers found across any requested region
({{ pia_effective_regions }}) — this should be unreachable given
the region-level assert above; if you see this, the serverlist
response shape has likely changed.
- name: Deploy PIA's CA bundle (WireGuard addKey endpoint only)
ansible.builtin.copy:
src: pia-ca.crt
dest: "{{ pia_wg_config_dir }}/pia-ca.crt"
mode: "0644"
owner: root
group: root
# Only the regional addKey endpoint (1337/addKey, below) needs
# this — it presents a cert chain that does not validate against a
# normal system CA store (confirmed live: curl exit 60, "unable to
# get local issuer certificate"), and PIA's own tooling pins this
# exact bundle rather than trusting the system store. The token
# endpoint (www.privateinternetaccess.com, below) is a normal
# public domain with a normal publicly-trusted cert and uses
# validate_certs: true / the system store — do not add --cacert or
# validate_certs: false there.
#
# Source: https://raw.githubusercontent.com/pia-foss/manual-connections/master/ca.rsa.4096.crt
# Re-fetched fresh and diffed byte-for-byte identical against the
# vendored copy on 2026-08-24.
# File SHA-256 (sha256sum of the .crt file's bytes — this is what
# to compare against a fresh `curl ... | sha256sum` to verify the
# vendored copy, NOT the same thing as the X.509 certificate
# fingerprint below):
# 32e9b1d1433ea97614f2a14c6e358e3f57c0570cc9f6b2ee812699ba696c66ab
# X.509 certificate fingerprint (openssl x509 -noout -fingerprint
# -sha256 — a hash of the DER-encoded certificate structure, a
# different value from the file SHA-256 above; useful for
# comparing against a cert viewed some other way, e.g. in a
# browser or `openssl s_client`):
# SHA256 Fingerprint=1F:D2:56:58:45:6E:AB:30:41:FB:A7:7C:CD:39:8A:B8:12:4E:DC:C1:B8:B2:FC:1D:55:FD:F6:B1:BB:FC:9D:70
- name: Request a PIA auth token
ansible.builtin.uri:
url: https://www.privateinternetaccess.com/api/client/v2/token
method: POST
body_format: form-multipart
body:
username: "{{ pia_user }}"
password: "{{ pia_password }}"
validate_certs: true
return_content: true
timeout: 15
register: pia_token_response
until: pia_token_response.status | default(0) == 200
retries: 3
delay: 5
failed_when: false
no_log: true
# Fixed public endpoint, system CA validation — see get_token.sh.
# Independent of pia_region entirely.
- name: Capture safe (non-credential) diagnostics from the token request
ansible.builtin.set_fact:
pia_token_status: "{{ pia_token_response.status | default(-1) }}"
pia_token_msg: "{{ pia_token_response.msg | default('') }}"
# Deliberately NOT no_log. .status (an HTTP code) and .msg (Ansible's
# own generated "Status code was N and not [200]: ..." text) never
# contain the credential, the token, or the request body — verified
# empirically before use here, not assumed. What DOES contain
# secrets and stays behind no_log: pia_token_response itself (its
# .json/.content on a 200 IS the token; its .invocation.module_args
# is the literal username/password), and pia_user/pia_password.
- name: Fail clearly if the auth token request failed
ansible.builtin.fail:
msg: >-
PIA auth token request failed: HTTP {{ pia_token_status }}
({{ pia_token_msg }}) from
www.privateinternetaccess.com/api/client/v2/token
when: pia_token_status | int != 200
- name: Extract the auth token
ansible.builtin.set_fact:
pia_auth_token: "{{ pia_token_response.json.token | default('') }}"
no_log: true
- name: Assert the token is non-empty
ansible.builtin.assert:
that:
- pia_auth_token | length > 0
fail_msg: "PIA token response (HTTP 200) did not contain a non-empty token"
- name: Attempt WireGuard key registration against each region/server candidate, in order
ansible.builtin.include_tasks: addkey-attempt.yaml
loop: "{{ pia_candidates }}"
loop_control:
loop_var: pia_candidate
# pia_candidates is already flattened and ordered: every server in
# the first requested region, then every server in the second, and
# so on (built above via subelements over pia_region_lookup — never
# re-sorted or grouped differently here). Every meaningful task in
# addkey-attempt.yaml is individually guarded with
# `when: pia_peer is not defined` (re-evaluated fresh for each loop
# iteration) — confirmed empirically that a guard on this include
# statement itself does NOT re-evaluate per iteration and would NOT
# actually stop later candidates from being attempted after an
# earlier one already succeeded; the guard has to live on the inner
# tasks, which is also where addkey-attempt.yaml resets its
# per-attempt state so a stale parsed response from one candidate
# can never be mistaken for another's.
- name: Assert PIA accepted the registration against at least one candidate
ansible.builtin.assert:
that:
- pia_peer is defined
- pia_peer.status == "OK"
- pia_region_used is defined
- pia_wg_server_used is defined
fail_msg: >-
PIA addKey failed against every candidate: {{ pia_candidates |
length }} server(s) across {{ pia_effective_regions | length }}
region(s) ({{ pia_effective_regions }}) all failed. See the
per-attempt diagnostics logged above for exactly why each one
failed (timeout, TLS, HTTP error, empty/malformed response, or
a parsed non-OK status). If every region failed the same way,
that points at something shared across all of them — the
request itself, credentials, or minisforum's own network path —
not at any one region being down.
- name: Write the pia-wg WireGuard interface config
ansible.builtin.template:
src: pia-wg.conf.j2
dest: "{{ pia_wg_config_dir }}/{{ pia_wg_interface }}.conf"
mode: "0600"
owner: root
group: root
vars:
pia_private_key: "{{ pia_private_key_raw.content | b64decode | trim }}"
no_log: true
notify: Restart pia-wg
# ansible.builtin.template writes atomically (temp file + rename),
# so a failure partway through this specific task cannot leave a
# partially-written config — the previous file, if any, is left
# untouched. Everything that can fail on PIA's side (registration,
# both API calls) happens strictly before this point, so the
# previous working config is never at risk from those failures
# either.
- name: Promote the rotated key now that registration and config write both succeeded
when: pia_force_key_rotation | bool
block:
- name: Check whether a previous key exists to back up
ansible.builtin.stat:
path: "{{ pia_wg_config_dir }}/{{ pia_wg_interface }}.key"
register: pia_previous_key_stat
- name: Back up the previous key
ansible.builtin.command:
cmd: >-
cp -p {{ pia_wg_config_dir }}/{{ pia_wg_interface }}.key
{{ pia_wg_config_dir }}/{{ pia_wg_interface }}.key.bak-{{ ansible_date_time.iso8601_basic_short }}
when: pia_previous_key_stat.stat.exists
changed_when: true
- name: Promote the new key into place
ansible.builtin.command:
cmd: >-
mv {{ pia_wg_config_dir }}/{{ pia_wg_interface }}.key.new
{{ pia_wg_config_dir }}/{{ pia_wg_interface }}.key
changed_when: true
# Only reached after the config template task above has already
# succeeded (Ansible blocks/tasks run sequentially and stop on
# first failure) — by this point PIA has accepted the new key and
# the new config referencing it is already safely on disk, so
# promoting the key file itself (for next run's `creates:` check
# to find it under the normal, non-.new name) is the only
# remaining step, not a point where failure could strand anything.