homelab/ansible/roles/pia-gateway/tasks/addkey-attempt.yaml
Nik Afiq 325d3bc5c7 feat: add pia-gateway role for minisforum PIA WireGuard egress
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>
2026-08-24 17:54:16 +09:00

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.