321 lines
9.2 KiB
Bash
321 lines
9.2 KiB
Bash
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
source /usr/local/lib/pia.sh
|
|
|
|
PIA_IFACE="${PIA_IFACE:-wg0}"
|
|
PIA_STATE_DIR="${PIA_STATE_DIR:-/pia}"
|
|
PIA_PF_ENABLED="${PIA_PORT_FORWARD:-false}"
|
|
PIA_PF_FILE="${PIA_PF_FILE:-$PIA_STATE_DIR/port_forward.json}"
|
|
PIA_PF_PORT_FILE="${PIA_PF_PORT_FILE:-$PIA_STATE_DIR/port_forward.port}"
|
|
PIA_DNS_ENABLE="${PIA_DNS_ENABLE:-true}"
|
|
PIA_DNS_FALLBACK="${PIA_DNS_FALLBACK:-10.0.0.242}"
|
|
PIA_DNS_PRE_VPN="${PIA_DNS_PRE_VPN:-1.1.1.1}"
|
|
PIA_HEALTH_URL="${PIA_HEALTH_URL:-https://api64.ipify.org}"
|
|
PIA_HANDSHAKE_MAX_AGE_SECONDS="${PIA_HANDSHAKE_MAX_AGE_SECONDS:-180}"
|
|
PIA_RECONNECT_BACKOFF_SECONDS="${PIA_RECONNECT_BACKOFF_SECONDS:-5}"
|
|
|
|
CA_CERT_PATH="/opt/pia/ca.rsa.4096.crt"
|
|
|
|
pf_pid=""
|
|
current_region=""
|
|
current_wg_ip=""
|
|
current_wg_cn=""
|
|
orig_default_route=""
|
|
orig_default_gw=""
|
|
orig_default_dev=""
|
|
|
|
cleanup() {
|
|
set +e
|
|
if [[ -n "$pf_pid" ]]; then
|
|
kill "$pf_pid" 2>/dev/null || true
|
|
wait "$pf_pid" 2>/dev/null || true
|
|
pf_pid=""
|
|
fi
|
|
iptables -D OUTPUT -j PIA_KILLSWITCH 2>/dev/null || true
|
|
iptables -F PIA_KILLSWITCH 2>/dev/null || true
|
|
iptables -X PIA_KILLSWITCH 2>/dev/null || true
|
|
ip link del "$PIA_IFACE" 2>/dev/null || true
|
|
if [[ -n "${orig_default_route:-}" ]]; then
|
|
ip route replace $orig_default_route 2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
trap cleanup EXIT INT TERM
|
|
|
|
ensure_state_dir() {
|
|
mkdir -p "$PIA_STATE_DIR"
|
|
chmod 0755 "$PIA_STATE_DIR"
|
|
}
|
|
|
|
write_resolv_conf() {
|
|
local dns="$1"
|
|
log "Setting /etc/resolv.conf to DNS $dns"
|
|
printf "nameserver %s\n" "$dns" > /etc/resolv.conf
|
|
}
|
|
|
|
setup_killswitch() {
|
|
local endpoint_ip="$1"
|
|
local endpoint_port="$2"
|
|
|
|
iptables -N PIA_KILLSWITCH 2>/dev/null || true
|
|
iptables -F PIA_KILLSWITCH
|
|
|
|
iptables -A PIA_KILLSWITCH -o lo -j ACCEPT
|
|
iptables -A PIA_KILLSWITCH -o "$PIA_IFACE" -j ACCEPT
|
|
iptables -A PIA_KILLSWITCH -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
|
|
|
# Allow negotiating the tunnel itself (endpoint traffic leaves on eth0)
|
|
iptables -A PIA_KILLSWITCH -p udp -d "$endpoint_ip" --dport "$endpoint_port" -j ACCEPT
|
|
|
|
# Allow local traffic (docker internal + loopback ranges)
|
|
iptables -A PIA_KILLSWITCH -d 127.0.0.0/8 -j ACCEPT
|
|
iptables -A PIA_KILLSWITCH -d 172.16.0.0/12 -j ACCEPT
|
|
iptables -A PIA_KILLSWITCH -d 192.168.0.0/16 -j ACCEPT
|
|
iptables -A PIA_KILLSWITCH -d 10.0.0.0/8 -j ACCEPT
|
|
|
|
iptables -A PIA_KILLSWITCH -j REJECT
|
|
|
|
iptables -C OUTPUT -j PIA_KILLSWITCH 2>/dev/null || iptables -I OUTPUT 1 -j PIA_KILLSWITCH
|
|
}
|
|
|
|
capture_default_route() {
|
|
# Save original default route so we can restore on exit/reconnect.
|
|
if [[ -n "${orig_default_route:-}" ]]; then
|
|
return
|
|
fi
|
|
orig_default_route="$(ip -4 route show default | head -n1 || true)"
|
|
orig_default_gw="$(awk '{for (i=1;i<=NF;i++) if ($i=="via") print $(i+1)}' <<<"$orig_default_route")"
|
|
orig_default_dev="$(awk '{for (i=1;i<=NF;i++) if ($i=="dev") print $(i+1)}' <<<"$orig_default_route")"
|
|
}
|
|
|
|
wg_up_manual() {
|
|
local iface="$1"
|
|
local addr="$2"
|
|
local privkey="$3"
|
|
local endpoint_ip="$4"
|
|
local endpoint_port="$5"
|
|
local server_pubkey="$6"
|
|
|
|
capture_default_route
|
|
if [[ -z "${orig_default_gw:-}" || -z "${orig_default_dev:-}" ]]; then
|
|
>&2 echo "Could not determine original default gateway/device."
|
|
return 1
|
|
fi
|
|
|
|
ip link del "$iface" 2>/dev/null || true
|
|
ip link add "$iface" type wireguard
|
|
ip -4 address add "$addr" dev "$iface"
|
|
ip link set mtu 1420 up dev "$iface"
|
|
|
|
wg set "$iface" private-key <(printf '%s' "$privkey")
|
|
wg set "$iface" peer "$server_pubkey" persistent-keepalive 25 allowed-ips 0.0.0.0/0 endpoint "${endpoint_ip}:${endpoint_port}"
|
|
|
|
# Ensure endpoint traffic keeps going out via the original gateway.
|
|
ip -4 route replace "${endpoint_ip}/32" via "$orig_default_gw" dev "$orig_default_dev"
|
|
|
|
# Route all egress through the tunnel (main table; avoids wg-quick sysctl/policy routing).
|
|
ip -4 route replace default dev "$iface"
|
|
}
|
|
|
|
is_tunnel_healthy() {
|
|
if ! wg show "$PIA_IFACE" >/dev/null 2>&1; then
|
|
return 1
|
|
fi
|
|
|
|
local now latest age
|
|
now="$(date +%s)"
|
|
latest="$(wg show "$PIA_IFACE" latest-handshakes 2>/dev/null | awk '{print $2}' | sort -nr | head -n1 || true)"
|
|
if [[ -z "$latest" || "$latest" == "0" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
age="$(( now - latest ))"
|
|
if (( age > PIA_HANDSHAKE_MAX_AGE_SECONDS )); then
|
|
return 1
|
|
fi
|
|
|
|
curl -fsS --max-time 5 "$PIA_HEALTH_URL" >/dev/null
|
|
}
|
|
|
|
start_port_forwarding_loop() {
|
|
local token="$1"
|
|
local wg_ip="$2"
|
|
local wg_cn="$3"
|
|
local region="$4"
|
|
|
|
(
|
|
set -euo pipefail
|
|
|
|
local sigresp status payload signature port expires_at bindresp
|
|
|
|
while true; do
|
|
sigresp="$(pia_pf_get_signature "$wg_ip" "$wg_cn" "$token" "$CA_CERT_PATH")"
|
|
status="$(jq -r '.status' <<<"$sigresp")"
|
|
if [[ "$status" != "OK" ]]; then
|
|
>&2 echo "PF getSignature did not return OK: $sigresp"
|
|
sleep 5
|
|
continue
|
|
fi
|
|
|
|
payload="$(jq -r '.payload' <<<"$sigresp")"
|
|
signature="$(jq -r '.signature' <<<"$sigresp")"
|
|
port="$(echo "$payload" | base64 -d | jq -r '.port')"
|
|
expires_at="$(echo "$payload" | base64 -d | jq -r '.expires_at')"
|
|
|
|
bindresp="$(pia_pf_bind_port "$wg_ip" "$wg_cn" "$payload" "$signature" "$CA_CERT_PATH")"
|
|
if [[ "$(jq -r '.status' <<<"$bindresp")" != "OK" ]]; then
|
|
>&2 echo "PF bindPort did not return OK: $bindresp"
|
|
sleep 10
|
|
continue
|
|
fi
|
|
|
|
ensure_state_dir
|
|
jq -n \
|
|
--arg region "$region" \
|
|
--arg server_ip "$wg_ip" \
|
|
--arg server_cn "$wg_cn" \
|
|
--argjson port "$port" \
|
|
--arg expires_at "$expires_at" \
|
|
--arg refreshed_at "$(date -Is)" \
|
|
'{region:$region,server:{ip:$server_ip,cn:$server_cn},port:$port,expires_at:$expires_at,refreshed_at:$refreshed_at}' \
|
|
> "$PIA_PF_FILE.tmp" \
|
|
&& mv "$PIA_PF_FILE.tmp" "$PIA_PF_FILE"
|
|
|
|
echo "$port" > "$PIA_PF_PORT_FILE.tmp" && mv "$PIA_PF_PORT_FILE.tmp" "$PIA_PF_PORT_FILE"
|
|
|
|
sleep 900
|
|
done
|
|
) &
|
|
|
|
pf_pid="$!"
|
|
log "Port-forward loop started (pid=$pf_pid)."
|
|
}
|
|
|
|
connect_region() {
|
|
local region="$1"
|
|
local wg_ip="$2"
|
|
local wg_cn="$3"
|
|
local token="$4"
|
|
|
|
log "Connecting region=$region wg=${wg_cn} (${wg_ip})"
|
|
|
|
local privKey pubKey wgjson status peer_ip server_key server_port dns_server
|
|
privKey="$(wg genkey)"
|
|
pubKey="$(echo "$privKey" | wg pubkey)"
|
|
|
|
wgjson="$(pia_wireguard_add_key "$wg_ip" "$wg_cn" "$token" "$pubKey" "$CA_CERT_PATH")"
|
|
status="$(jq -r '.status' <<<"$wgjson")"
|
|
if [[ "$status" != "OK" ]]; then
|
|
>&2 echo "WireGuard addKey failed: $wgjson"
|
|
return 1
|
|
fi
|
|
|
|
peer_ip="$(jq -r '.peer_ip' <<<"$wgjson")"
|
|
server_key="$(jq -r '.server_key' <<<"$wgjson")"
|
|
server_port="$(jq -r '.server_port' <<<"$wgjson")"
|
|
dns_server="$(jq -r '.dns_servers[0] // empty' <<<"$wgjson")"
|
|
if [[ -z "$dns_server" ]]; then
|
|
dns_server="$PIA_DNS_FALLBACK"
|
|
fi
|
|
|
|
# Ensure we can still resolve endpoints before tunnel is up.
|
|
# Once the tunnel is up, we can switch to PIA-provided DNS.
|
|
if [[ "$PIA_DNS_ENABLE" == "true" ]]; then
|
|
write_resolv_conf "$PIA_DNS_PRE_VPN"
|
|
fi
|
|
|
|
wg_up_manual "$PIA_IFACE" "$peer_ip" "$privKey" "$wg_ip" "$server_port" "$server_key"
|
|
|
|
if [[ "$PIA_DNS_ENABLE" == "true" ]]; then
|
|
write_resolv_conf "$dns_server"
|
|
fi
|
|
|
|
setup_killswitch "$wg_ip" "$server_port"
|
|
|
|
current_region="$region"
|
|
current_wg_ip="$wg_ip"
|
|
current_wg_cn="$wg_cn"
|
|
|
|
log "Connected. Verifying egress."
|
|
curl -fsS --max-time 10 "$PIA_HEALTH_URL" | tr -d '\n' | sed 's/^/PublicIP=/' || true
|
|
echo
|
|
|
|
if [[ "$PIA_PF_ENABLED" == "true" ]]; then
|
|
start_port_forwarding_loop "$token" "$wg_ip" "$wg_cn" "$region"
|
|
fi
|
|
}
|
|
|
|
main() {
|
|
require_env PIA_USER
|
|
require_env PIA_PASS
|
|
require_env PIA_REGIONS
|
|
|
|
ensure_state_dir
|
|
log "Authenticating to PIA."
|
|
local token
|
|
token="$(pia_get_token)"
|
|
|
|
log "Fetching serverlist."
|
|
local serverlist
|
|
serverlist="$(pia_serverlist_v4)"
|
|
|
|
local candidates
|
|
candidates="$(pia_pick_region_candidates <<<"$serverlist")"
|
|
if [[ -z "$candidates" ]]; then
|
|
>&2 echo "No matching WireGuard servers found for PIA_REGIONS=$(printf %q "${PIA_REGIONS}")"
|
|
exit 4
|
|
fi
|
|
|
|
log "Starting connect loop."
|
|
local line region wg_ip wg_cn connected=0
|
|
while IFS= read -r line; do
|
|
region="${line%%|*}"
|
|
wg_ip="$(cut -d'|' -f2 <<<"$line")"
|
|
wg_cn="$(cut -d'|' -f3 <<<"$line")"
|
|
|
|
if connect_region "$region" "$wg_ip" "$wg_cn" "$token"; then
|
|
connected=1
|
|
break
|
|
fi
|
|
done <<<"$candidates"
|
|
|
|
if (( connected == 0 )); then
|
|
>&2 echo "Failed to connect to any region in PIA_REGIONS."
|
|
exit 5
|
|
fi
|
|
|
|
# Recovery loop (runs forever)
|
|
local idx=0 total
|
|
total="$(wc -l <<<"$candidates" | tr -d ' ')"
|
|
|
|
while true; do
|
|
sleep 15
|
|
if is_tunnel_healthy; then
|
|
continue
|
|
fi
|
|
|
|
log "Tunnel unhealthy. Reconnecting (rotate region)."
|
|
cleanup
|
|
|
|
idx=$(( (idx + 1) % total ))
|
|
line="$(sed -n "$((idx + 1))p" <<<"$candidates")"
|
|
region="${line%%|*}"
|
|
wg_ip="$(cut -d'|' -f2 <<<"$line")"
|
|
wg_cn="$(cut -d'|' -f3 <<<"$line")"
|
|
|
|
until connect_region "$region" "$wg_ip" "$wg_cn" "$token"; do
|
|
log "Reconnect failed; sleeping ${PIA_RECONNECT_BACKOFF_SECONDS}s."
|
|
sleep "$PIA_RECONNECT_BACKOFF_SECONDS"
|
|
idx=$(( (idx + 1) % total ))
|
|
line="$(sed -n "$((idx + 1))p" <<<"$candidates")"
|
|
region="${line%%|*}"
|
|
wg_ip="$(cut -d'|' -f2 <<<"$line")"
|
|
wg_cn="$(cut -d'|' -f3 <<<"$line")"
|
|
done
|
|
done
|
|
}
|
|
|
|
main "$@"
|
|
|