393 lines
20 KiB
YAML
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.
|