--- # Part of role: pia-gateway # Included by: tasks/register.yaml, once per candidate in pia_candidates # (loop_var: pia_candidate — a flattened {region_id, region_name, cn, ip} # dict, one server, built by register.yaml via subelements over every # requested region in order), in order, until one succeeds. # # Every meaningful task below is individually guarded with # `when: pia_peer is not defined` — confirmed empirically (not assumed) # that putting this guard only on the include statement in register.yaml # does NOT stop later loop iterations from running; ansible re-evaluates # a `when` on tasks inside an included file per-iteration, but does not # re-evaluate a `when` on the include statement itself the same way. The # guard has to live here, on every task, or a candidate that comes up # after an earlier one already worked would still get hit. - name: "Reset per-attempt response state for {{ pia_candidate.cn }}" ansible.builtin.set_fact: pia_addkey_response: {} pia_addkey_rc: -1 pia_addkey_stderr: "" pia_addkey_parsed: null pia_addkey_diagnosis: "" when: pia_peer is not defined # Explicit reset, every iteration, before this candidate's own curl # call runs — a prior version of this file only ever *conditionally* # set pia_addkey_parsed (rc == 0 and non-empty stdout), so a candidate # whose curl call failed outright (rc != 0) would skip that task and # silently keep the PREVIOUS candidate's parsed response sitting in # pia_addkey_parsed. That's not just cosmetic: it means a stale # {"status": "..."} from an earlier failure could read as if it came # from the current candidate in diagnostics, or — if the guard logic # were ever refactored slightly differently — actually be accepted for # the wrong server. Resetting to a known-empty/null state here, every # time, makes each iteration self-contained regardless of what # happened before it. - name: "Register WireGuard key with server {{ pia_candidate.cn }}" ansible.builtin.command: cmd: >- curl --silent --show-error --fail --location -G --connect-timeout 10 --max-time 30 --cacert {{ pia_wg_config_dir }}/pia-ca.crt --connect-to {{ pia_candidate.cn }}::{{ pia_candidate.ip }}: --data-urlencode pt@- --data-urlencode pubkey={{ pia_public_key.stdout }} https://{{ pia_candidate.cn }}:1337/addKey args: stdin: "{{ pia_auth_token }}" stdin_add_newline: false register: pia_addkey_response changed_when: false failed_when: false no_log: true when: pia_peer is not defined # Same request shape as PIA's own connect_to_wireguard_with_token.sh # for every region — WireGuard hostname for TLS/SNI, its server IP via # --connect-to, PIA's private CA (never -k/--insecure/validate_certs: # false), port 1337, /addKey, generated pubkey. Token passed via stdin # (--data-urlencode pt@-) rather than as a plain argv value — verified # empirically (this session) that curl's argv, including a literal # "pt@-", never the token itself, is what shows up in `ps`, so the # token is never visible to another local user on the host for the # process's lifetime. pubkey is not sensitive (public by design) and # stays a normal argument. No curl --retry here — retries happen by # moving to the next candidate in the loop instead, so one slow/dead # server can't eat the whole attempt budget retrying itself before # failover to the next server, or the next region, gets a chance. # # stdin_add_newline: false is load-bearing, not cosmetic. Ansible's # command module appends a trailing newline to `stdin` by default # (stdin_add_newline defaults to true) — confirmed live (this session) # against a real run: every one of 7 servers across all 3 regions # rejected the token with a uniform HTTP 401, and reproducing the exact # same stdin mechanism against a plain echo endpoint showed why — # `--data-urlencode pt@-` does not strip that trailing newline, so PIA # received "\n" (URL-encoded, with a trailing %0A) as the # pt value: a different, invalid string. This is what actually broke # every region identically, not a credentials or connectivity problem # in any of them. get_token.sh/connect_to_wireguard_with_token.sh never # hit this because they pass the token as a literal shell variable # (`"pt=${PIA_TOKEN}"`), not via stdin — the stdin delivery mechanism # itself was introduced in this repo specifically to keep the token out # of `ps` output, and needs this flag to behave the same as PIA's own # reference flow. - name: "Capture safe diagnostics for {{ pia_candidate.cn }}" ansible.builtin.set_fact: pia_addkey_rc: "{{ pia_addkey_response.rc | default(-1) }}" pia_addkey_stderr: "{{ pia_addkey_response.stderr | default('') }}" when: pia_peer is not defined # rc/stderr never contain the token or pubkey — same reasoning as the # token request's diagnostics in register.yaml. Never derived from # pia_addkey_response.cmd/.invocation (the literal argv, harmless here # since the token isn't in it either, but still not something to print # wholesale) — only the two specific safe fields are ever extracted. # --- Classify the outcome into one human-readable diagnosis. Each of # these is mutually exclusive by its `when:`, and pia_addkey_diagnosis # was reset to "" above, so exactly one (or zero, if this candidate was # skipped because an earlier one already succeeded) ends up set. Curl # exit codes confirmed against curl's own documented libcurl error list, # not guessed — 28 is CURLE_OPERATION_TIMEDOUT, 22 is # CURLE_HTTP_RETURNED_ERROR (only possible here because of --fail), and # the TLS set is every CURLE_SSL_*/CURLE_PEER_FAILED_VERIFICATION code # curl currently defines (35, 53, 54, 58, 59, 60, 66, 77, 80, 82, 83). - name: "Diagnose a connection timeout for {{ pia_candidate.cn }}" ansible.builtin.set_fact: pia_addkey_diagnosis: connection timeout when: (pia_peer is not defined) and (pia_addkey_rc | int == 28) - name: "Diagnose a TLS validation failure for {{ pia_candidate.cn }}" ansible.builtin.set_fact: pia_addkey_diagnosis: TLS/SSL failure when: (pia_peer is not defined) and (pia_addkey_rc | int in [35, 53, 54, 58, 59, 60, 66, 77, 80, 82, 83]) - name: "Diagnose an HTTP failure for {{ pia_candidate.cn }}" ansible.builtin.set_fact: pia_addkey_diagnosis: HTTP failure (non-2xx response) when: (pia_peer is not defined) and (pia_addkey_rc | int == 22) - name: "Diagnose an unclassified curl error for {{ pia_candidate.cn }}" ansible.builtin.set_fact: pia_addkey_diagnosis: "curl error (exit {{ pia_addkey_rc }})" when: (pia_peer is not defined) and (pia_addkey_rc | int not in [0, 22, 28, 35, 53, 54, 58, 59, 60, 66, 77, 80, 82, 83]) - name: "Parse the addKey response from {{ pia_candidate.cn }}" ansible.builtin.set_fact: pia_addkey_parsed: "{{ pia_addkey_response.stdout | from_json }}" when: (pia_peer is not defined) and (pia_addkey_rc | int == 0) and (pia_addkey_response.stdout | trim | length > 0) ignore_errors: true # ignore_errors here on purpose: a genuinely malformed body from one # misbehaving server must not abort the whole play — it should just # leave pia_addkey_parsed at the null it was reset to above, so this # candidate is diagnosed as malformed (below) and the loop moves on. - name: "Diagnose an empty response for {{ pia_candidate.cn }}" ansible.builtin.set_fact: pia_addkey_diagnosis: empty response body when: (pia_peer is not defined) and (pia_addkey_rc | int == 0) and (pia_addkey_response.stdout | trim | length == 0) - name: "Diagnose a malformed JSON response for {{ pia_candidate.cn }}" ansible.builtin.set_fact: pia_addkey_diagnosis: malformed JSON response when: (pia_peer is not defined) and (pia_addkey_rc | int == 0) and (pia_addkey_response.stdout | trim | length > 0) and (pia_addkey_parsed is none) - name: "Diagnose a non-OK status for {{ pia_candidate.cn }}" ansible.builtin.set_fact: pia_addkey_diagnosis: "parsed OK but status is '{{ pia_addkey_parsed.status | default('missing') }}', not OK" when: (pia_peer is not defined) and (pia_addkey_parsed is not none) and ((pia_addkey_parsed.status | default('')) != 'OK') - name: "Log the attempt outcome for {{ pia_candidate.cn }}" ansible.builtin.debug: msg: >- region={{ pia_candidate.region_name }} ({{ pia_candidate.region_id }}) server={{ pia_candidate.cn }} ip={{ pia_candidate.ip }} rc={{ pia_addkey_rc }} reason=[{{ pia_addkey_diagnosis }}] stderr=[{{ pia_addkey_stderr }}] status={{ pia_addkey_parsed.status | default('n/a') }} when: (pia_peer is not defined) and (pia_addkey_diagnosis | length > 0) # Deliberately never includes: the token, username/password, the full # curl invocation/cmd array, request stdin, the complete response body # (only its parsed .status field, when parsing succeeded), or any key # material beyond what pubkey already is (public, safe by design). - name: "Accept the response from {{ pia_candidate.cn }}" ansible.builtin.set_fact: pia_peer: "{{ pia_addkey_parsed }}" pia_region_used: "{{ pia_candidate.region_id }}" pia_region_name_used: "{{ pia_candidate.region_name }}" pia_wg_server_used: "{{ pia_candidate }}" when: (pia_peer is not defined) and (pia_addkey_parsed is not none) and ((pia_addkey_parsed.status | default('')) == 'OK') # Deliberately checks status == OK here, not just "did it parse" — a # response that parses but isn't actually OK must not stop the loop # from trying the remaining candidates. Once this fires, every # subsequent iteration's tasks are no-ops (all guarded on # `pia_peer is not defined`), so pia_region_used/pia_wg_server_used are # never overwritten by a later candidate.