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>
111 lines
4.6 KiB
Django/Jinja
111 lines
4.6 KiB
Django/Jinja
#!/bin/bash
|
|
# Managed by Ansible (role: pia-gateway). Do not edit by hand.
|
|
#
|
|
# Observability only. This script NEVER modifies routing, firewall, or
|
|
# WireGuard state — a failed check must close the path (already true by
|
|
# construction, via the kill switch in tasks/routing.yaml/firewall.yaml),
|
|
# never "recover" it. Output goes to the journal
|
|
# (journalctl -u pia-gateway-healthcheck); wire alerting off of that
|
|
# separately (see plan.md Phase 9) rather than adding remediation here.
|
|
#
|
|
# The "route decision" check below confirms what the kernel FIB *would*
|
|
# do for a VLAN 50 source — it does not send a packet and is not proof of
|
|
# actual PIA egress or identity. That proof requires the packet-capture
|
|
# test matrix in plan.md Phase 5; do not treat a clean run of this script
|
|
# as substituting for it.
|
|
set -uo pipefail
|
|
|
|
WG_IF="{{ pia_wg_interface }}"
|
|
TABLE="{{ pia_route_table_name }}"
|
|
SUBNET="{{ pia_vlan50_subnet }}"
|
|
PRIORITY="{{ pia_rule_priority }}"
|
|
CHAIN="{{ pia_iptables_chain }}"
|
|
MAX_HANDSHAKE_AGE={{ pia_healthcheck_handshake_max_age_sec }}
|
|
PROBE_SRC="${SUBNET%.*}.2" # a representative VLAN 50 address for the route-decision probe only; never actually used as a source
|
|
|
|
fail=0
|
|
|
|
# Informational only (not pass/fail) — which region/server this host is
|
|
# actually registered against right now, per the comment
|
|
# tasks/register.yaml writes into the generated config. Since PIA
|
|
# WireGuard registration now falls back across multiple regions
|
|
# (pia_region_candidates in defaults/main.yaml), "which one is live"
|
|
# isn't implied by config alone — it's whichever one answered OK last
|
|
# time the playbook ran, not necessarily the first-configured one.
|
|
configured_peer=$(grep -m1 '^# PIA WireGuard server actually used' "{{ pia_wg_config_dir }}/{{ pia_wg_interface }}.conf" 2>/dev/null || true)
|
|
if [[ -n "${configured_peer}" ]]; then
|
|
echo "INFO configured-peer: ${configured_peer#\# }"
|
|
fi
|
|
|
|
if ip link show "${WG_IF}" up &>/dev/null; then
|
|
echo "PASS interface: ${WG_IF} is up"
|
|
else
|
|
echo "FAIL interface: ${WG_IF} is not up"
|
|
fail=1
|
|
fi
|
|
|
|
if command -v wg &>/dev/null && wg show "${WG_IF}" latest-handshakes &>/dev/null; then
|
|
handshake_epoch=$(wg show "${WG_IF}" latest-handshakes 2>/dev/null | awk '{print $2}')
|
|
now=$(date +%s)
|
|
if [[ -n "${handshake_epoch}" && "${handshake_epoch}" -gt 0 ]]; then
|
|
age=$((now - handshake_epoch))
|
|
if (( age <= MAX_HANDSHAKE_AGE )); then
|
|
echo "PASS handshake: ${age}s old (max ${MAX_HANDSHAKE_AGE}s)"
|
|
else
|
|
echo "FAIL handshake: ${age}s old, exceeds max ${MAX_HANDSHAKE_AGE}s"
|
|
fail=1
|
|
fi
|
|
else
|
|
echo "FAIL handshake: no handshake recorded yet"
|
|
fail=1
|
|
fi
|
|
else
|
|
echo "WARN handshake: unable to query wg show (interface absent or wg missing)"
|
|
fail=1
|
|
fi
|
|
|
|
if ip rule show | grep -qE "^${PRIORITY}:[[:space:]]*from ${SUBNET} lookup ${TABLE}\$"; then
|
|
echo "PASS rule: from ${SUBNET} lookup ${TABLE} present at priority ${PRIORITY}"
|
|
else
|
|
echo "FAIL rule: from ${SUBNET} lookup ${TABLE} missing"
|
|
fail=1
|
|
fi
|
|
|
|
default_route=$(ip route show table "${TABLE}" 2>/dev/null | grep '^default' || true)
|
|
if [[ "${default_route}" == *"dev ${WG_IF}"* ]]; then
|
|
echo "PASS route: table ${TABLE} default is via ${WG_IF} (open)"
|
|
elif [[ "${default_route}" == *unreachable* ]] || ip route show table "${TABLE}" 2>/dev/null | grep -q '^unreachable default'; then
|
|
echo "PASS route: table ${TABLE} default is unreachable (closed, kill switch engaged)"
|
|
else
|
|
echo "FAIL route: table ${TABLE} has no default route at all (neither open via ${WG_IF} nor a terminal unreachable) — investigate immediately"
|
|
fail=1
|
|
fi
|
|
|
|
# Not a check on FORWARD's own default policy — that's deliberately left
|
|
# alone (see tasks/firewall.yaml for why: minisforum runs Flannel, which
|
|
# needs its own broad FORWARD ACCEPTs). What actually matters is that the
|
|
# dedicated chain exists, is jumped into first for this source, and ends
|
|
# in a real DROP.
|
|
if iptables -C FORWARD -s "${SUBNET}" -j "${CHAIN}" 2>/dev/null; then
|
|
echo "PASS firewall: FORWARD jumps to ${CHAIN} for ${SUBNET}"
|
|
else
|
|
echo "FAIL firewall: FORWARD does not jump to ${CHAIN} for ${SUBNET}"
|
|
fail=1
|
|
fi
|
|
|
|
if iptables -S "${CHAIN}" 2>/dev/null | grep -qE '^-A '"${CHAIN}"' -j DROP$'; then
|
|
echo "PASS firewall: ${CHAIN} ends in an unconditional DROP"
|
|
else
|
|
echo "FAIL firewall: ${CHAIN} has no unconditional DROP catch-all"
|
|
fail=1
|
|
fi
|
|
|
|
route_decision=$(ip route get 1.1.1.1 from "${PROBE_SRC}" 2>&1 || true)
|
|
if [[ "${route_decision}" == *"dev ${WG_IF}"* ]]; then
|
|
echo "PASS route-decision (not proof of live egress — see header comment): ${SUBNET} sourced traffic resolves via ${WG_IF}"
|
|
else
|
|
echo "WARN route-decision (not proof of live egress — see header comment): ${route_decision}"
|
|
fi
|
|
|
|
exit "${fail}"
|