--- # 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= 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= 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.