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>
180 lines
9.6 KiB
YAML
180 lines
9.6 KiB
YAML
---
|
|
# 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 "<real token>\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.
|