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

93 lines
4.8 KiB
Django/Jinja

#!/bin/bash
# Managed by Ansible (role: pia-gateway). Do not edit by hand.
#
# Runs once at boot, before wg-quick@{{ pia_wg_interface }}.service
# (enforced by the systemd drop-in in pia-killswitch.service's
# Before=/wg-quick's Requires=After=), and again any time UFW is
# reloaded (see handlers/main.yaml — "Reload ufw" fires before "Restart
# pia-killswitch" so a reload can never leave a permanent gap). Seeds the
# closed state that PIA gateway's kill switch relies on:
#
# - a source rule sending {{ pia_vlan50_subnet }} to the dedicated
# "{{ pia_route_table_name }}" table, evaluated after the kernel's
# built-in "local" rule (priority 0) and before "main" (32766) — this
# is what keeps Technitium (10.10.40.53, a local address) delivered
# via the local table rather than pulled into policy routing.
# - a terminal "unreachable" default route in that table — but only
# when {{ pia_wg_interface }} isn't already up — so VLAN 50 traffic
# fails closed instead of falling through to main if the interface is
# down, absent, or its PostUp hasn't run yet, without this script
# stomping an already-open, healthy route on a restart unrelated to
# the tunnel's own state (see the check below).
# - a DEDICATED "{{ pia_iptables_chain }}" chain, jumped into by exactly
# one rule at the very top of FORWARD (position 1), matching only
# source {{ pia_vlan50_subnet }}. This is deliberately NOT a global
# FORWARD default-policy change: minisforum is the k3s server and
# runs Flannel, which depends on its own broad FORWARD-chain
# ACCEPTs for pod-to-pod and pod-to-internet traffic (source
# 10.42.0.0/16, entirely disjoint from 10.10.50.0/24) — changing the
# chain-wide default policy was never actually proven safe against
# that, so this scopes the kill switch to a chain that Flannet/k3s
# traffic can never enter in the first place, rather than risking it.
# Being the FIRST rule in FORWARD also means no other, later rule
# (ufw's own, Docker's, anything) can accidentally pre-empt this
# source's fate with a broader ACCEPT — the jump is unconditional for
# this source and the chain itself ends in an unconditional DROP, not
# a RETURN, so nothing after it in FORWARD is ever consulted for
# this source either.
#
# Idempotent: safe to run more than once (e.g. `systemctl restart
# pia-killswitch`, or after this script re-runs following a UFW reload)
# without creating duplicate rules or duplicate chains.
set -euo pipefail
TABLE="{{ pia_route_table_name }}"
SUBNET="{{ pia_vlan50_subnet }}"
WG_IF="{{ pia_wg_interface }}"
PRIORITY="{{ pia_rule_priority }}"
CHAIN="{{ pia_iptables_chain }}"
if ! ip rule show | grep -qE "^${PRIORITY}:[[:space:]]*from ${SUBNET} lookup ${TABLE}\$"; then
ip rule add from "${SUBNET}" table "${TABLE}" priority "${PRIORITY}"
fi
# Only seed the terminal-unreachable fallback if {{ pia_wg_interface }}
# isn't already up. This script re-runs any time it's restarted for
# reasons unrelated to the tunnel's own state (a UFW reload, a content
# fix to this script itself) — if the interface is already up and
# healthy, its own PostUp already set the real "default dev %i" route in
# this table, and blindly overwriting that back to "unreachable" here
# would break a working tunnel's routing for no reason until something
# re-runs PostUp. Only actual absence of the interface should close it.
if ! ip link show "${WG_IF}" up &>/dev/null; then
ip route replace unreachable default table "${TABLE}"
fi
# Dedicated chain: create if missing (iptables -N fails harmlessly if it
# already exists, hence || true rather than a -C-style existence check —
# there is no direct "does this chain exist" check short of grepping -L).
iptables -N "${CHAIN}" 2>/dev/null || true
# Exactly one jump from FORWARD into it, at absolute position 1.
if ! iptables -C FORWARD -s "${SUBNET}" -j "${CHAIN}" 2>/dev/null; then
iptables -I FORWARD 1 -s "${SUBNET}" -j "${CHAIN}"
fi
# Static catch-all inside the chain: anything that reaches this point
# (i.e., wasn't already ACCEPTed by a more specific rule that
# pia-wg.conf.j2's PostUp inserts ahead of these when the interface is
# up) is logged and dropped. --log-limit is not a real iptables option
# (there is no such flag — rate-limiting a LOG target is done via a
# separate -m limit match ahead of it, as below); the -C check must
# match the rule as actually inserted, term for term, or it will never
# find it and silently re-append a duplicate on every run.
if ! iptables -C "${CHAIN}" -m limit --limit 5/minute -j LOG \
--log-prefix "PIA-KILLSWITCH-DROP: " 2>/dev/null; then
iptables -A "${CHAIN}" -m limit --limit 5/minute -j LOG \
--log-prefix "PIA-KILLSWITCH-DROP: "
fi
if ! iptables -C "${CHAIN}" -j DROP 2>/dev/null; then
iptables -A "${CHAIN}" -j DROP
fi