Skip to content

Instantly share code, notes, and snippets.

@pedro0311
Last active February 9, 2026 14:28
Show Gist options
  • Select an option

  • Save pedro0311/0fa19ba7d425c471575c820eb16ef9d8 to your computer and use it in GitHub Desktop.

Select an option

Save pedro0311/0fa19ba7d425c471575c820eb16ef9d8 to your computer and use it in GitHub Desktop.
Script to set up PIA wireguard on FreshTomato
#!/usr/bin/env ash
# shellcheck shell=dash
set -eu # Exit on error or undefined variable
# (borrowed from: https://github.com/rveznaver/pia-freshtomato)
# requirements:
# - FreshTomato >= 2025.5 or some Linux distro
# - wg kernel module for WireGuard
# - curl for API requests
# - php for JSON parsing
# - openssl for RSA signature verification and base64 encoding/decoding
# - ipset with kernel modules: ip_set, ip_set_hash_ip, xt_set for VPN bypass
# - Standard POSIX tools: sed, grep
export PATH='/bin:/usr/bin:/sbin:/usr/sbin' # set PATH in case we run inside a cron
if ! type "php" >/dev/null 2>&1; then php () { php-cli "$@" ; }; fi # FreshTomato PHP is called php-cli
# Cleanup temporary files on exit
trap 'rm -f pia_tmp_*' EXIT
# Error handler - logs to syslog and exits
error_exit() {
echo "[!] ERROR: $1" >&2
logger -t pia_wireguard "ERROR: $1"
exit 1
}
healthcheck_tunnel() {
# Load config
# shellcheck disable=SC1091
[ -f pia_config ] && . ./pia_config
# Skip on first run (no session state to check)
[ -n "${auth_peer_ip:-}" ] || {
echo '[=] No session state, skipping healthcheck'
return 0
}
echo '[ ] Checking tunnel health...'
[ -d /sys/class/net/wg0 ] || {
echo '[*] wg0 not ready yet'
logger -t pia_wireguard "[*] wg0 not ready yet"
return 1
}
# TX check (matches official client transfer monitoring)
local var_tx_before var_tx_after
var_tx_before=$(wg show wg0 transfer 2>/dev/null | awk 'NR==1 {print $3+0}')
# RX substitute: FreshTomato WG RX counter is always 0, so ping confirms
# the return path is working. Also generates traffic on a fresh tunnel.
ping -I wg0 -c 1 -W 1 10.0.0.1 >/dev/null 2>&1 || {
echo '[*] Metadata ping failed (no return path)'
logger -t pia_wireguard '[*] Metadata ping failed (no return path)'
return 1
}
var_tx_after=$(wg show wg0 transfer 2>/dev/null | awk 'NR==1 {print $3+0}')
[ "${var_tx_after}" -gt "${var_tx_before}" ] || {
echo "[*] TX did not increase after probe (before=${var_tx_before} after=${var_tx_after})"
logger -t pia_wireguard "[*] TX did not increase (before=${var_tx_before} after=${var_tx_after})"
return 1
}
local var_timeout=300 # 5 minutes
local var_handshake_epoch var_now var_handshake_age
var_handshake_epoch=$(wg show wg0 latest-handshakes 2>/dev/null | awk 'NR==1 {print $2+0}')
var_now=$(date +%s)
var_handshake_age=$((var_now - var_handshake_epoch))
# Empty/invalid/zero epoch coerces to 0 via awk +0, producing an age
# equal to var_now (~decades), which always exceeds the timeout.
[ "${var_handshake_age}" -lt "${var_timeout}" ] || {
echo "[*] Handshake too old (${var_handshake_age}s)"
logger -t pia_wireguard "[*] Handshake too old (${var_handshake_age}s)"
return 1
}
echo "[=] Tunnel healthy (handshake age: ${var_handshake_age}s)"
return 0
}
init_script() {
echo '[ ] Initializing script...'
# Load config
# shellcheck disable=SC1091
[ -f pia_config ] && . ./pia_config
# Validate required variables
[ -z "${pia_user:-}" ] && error_exit "pia_user not set"
[ -z "${pia_pass:-}" ] && error_exit "pia_pass not set"
# Set default region if not set
if [ -z "${pia_vpn:-}" ]; then
echo '[*] pia_vpn (region) not set, defaulting to ca_ontario (Ontario, Canada)'
pia_vpn='ca_ontario'
fi
# Set default port forwarding if not set
if [ -z "${pia_pf:-}" ]; then
echo '[*] pia_pf (port forwarding) not set, defaulting to false'
pia_pf='false'
fi
# Validate pia_pf format (must be IP:PORT or false)
if [ "${pia_pf}" != 'false' ]; then
echo "${pia_pf}" | grep -q '^[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}:[0-9]\{1,5\}$' || error_exit "pia_pf must be in format IP:PORT (e.g., 192.168.1.10:22)"
fi
# Set default bypass IPs if not set (Google RCS servers)
if [ -z "${pia_bypass:-}" ]; then
echo '[*] pia_bypass (split tunneling by IP) not set, defaulting to Google RCS servers'
pia_bypass='216.239.36.127 216.239.36.131 216.239.36.132 216.239.36.133 216.239.36.134 216.239.36.135 216.239.36.145'
fi
# Validate bypass IPs (prevent injection)
if [ "${pia_bypass}" != 'false' ]; then
for ip in ${pia_bypass}; do
echo "${ip}" | grep -q '^[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}$' || error_exit "Invalid IP in pia_bypass: ${ip}"
done
fi
# Set default DuckDNS if not set
if [ -z "${pia_duckdns:-}" ]; then
echo '[*] pia_duckdns (DuckDNS dynamic DNS) not set, defaulting to false'
pia_duckdns='false'
fi
# Validate pia_duckdns format (must be DOMAIN:TOKEN or false)
if [ "${pia_duckdns}" != 'false' ]; then
echo "${pia_duckdns}" | grep -q ':' || error_exit "pia_duckdns must be in format DOMAIN:TOKEN"
fi
# Save credentials to config (preserve other variables)
local vars_init
vars_init=$(cat <<EOF
pia_user="${pia_user}"
pia_pass="${pia_pass}"
pia_vpn="${pia_vpn}"
pia_pf="${pia_pf}"
pia_bypass="${pia_bypass}"
pia_duckdns="${pia_duckdns}"
EOF
)
printf "%s\n%s\n" "$(grep -v '^pia_' pia_config 2>/dev/null || true)" "${vars_init}" > pia_config
echo '[+] Script ready'
}
init_module() {
echo '[ ] Initializing WireGuard...'
modprobe wireguard || error_exit "Failed to load wireguard module"
ip link show | grep -q 'wg0' || ip link add wg0 type wireguard || error_exit "Failed to create wg0 interface"
# Disable IPv6 on wg0 immediately after creation (PIA does not support IPv6)
# This prevents "Could not create IPv6 socket" error when bringing up the interface
if [ -d /proc/sys/net/ipv6/conf/wg0 ]; then
echo 1 > /proc/sys/net/ipv6/conf/wg0/disable_ipv6 2>/dev/null || {
echo "[!] WARNING: Could not disable IPv6 on wg0"
logger -t pia_wireguard "WARNING: Could not disable IPv6 on wg0"
}
fi
echo '[+] WireGuard ready'
}
get_cert() {
echo '[ ] Downloading PIA certificate...'
# Load config
# shellcheck disable=SC1091
[ -f pia_config ] && . ./pia_config
# Skip if certificate already exists (idempotent)
if [ -n "${certificate:-}" ]; then
echo '[=] Certificate already exists'
return 0
fi
# Download certificate
local var_cert
var_cert=$(curl --retry 5 --retry-all-errors -Ss 'https://raw.githubusercontent.com/pia-foss/manual-connections/master/ca.rsa.4096.crt')
[ -n "${var_cert}" ] || error_exit "Certificate download failed"
# Save to config (base64 encoded)
local var_cert_encoded
var_cert_encoded=$(echo "${var_cert}" | openssl base64 -A)
printf "%s\n%s\n" "$(grep -v '^certificate=' pia_config 2>/dev/null || true)" "certificate=\"${var_cert_encoded}\"" > pia_config
echo '[+] Certificate ready'
}
get_region() {
echo '[ ] Fetching PIA region info...'
# Load config
# shellcheck disable=SC1091
[ -f pia_config ] && . ./pia_config
# Check if region changed (cascade invalidation)
if [ -n "${region_id:-}" ] && [ "${region_id}" != "${pia_vpn}" ]; then
echo "[~] Region changed from ${region_id} to ${pia_vpn}, clearing dependent data..."
logger -t pia_wireguard "Region changed from ${region_id} to ${pia_vpn}"
# Clear region, token, auth, and portforward data
printf "%s\n" "$(grep -v '^region_\|^token=\|^auth_\|^portforward_' pia_config 2>/dev/null || true)" > pia_config
# Reload config after clearing
# shellcheck disable=SC1091
[ -f pia_config ] && . ./pia_config
fi
# Skip if region info already exists for current region (idempotent)
if [ -n "${region_meta_cn:-}" ] && [ -n "${region_wg_cn:-}" ] && [ "${region_id:-}" = "${pia_vpn}" ]; then
echo '[=] Region info already exists'
return 0
fi
# Fetch server list with signature
local var_response var_json var_signature
var_response=$(curl --retry 5 --retry-all-errors -Ss 'https://serverlist.piaservers.net/vpninfo/servers/v7')
var_json=$(echo "${var_response}" | head -1)
var_signature=$(echo "${var_response}" | tail -n 6)
# Verify signature using PIA's hardcoded RSA public key
# https://github.com/pia-foss/manual-connections/issues/21
cat > pia_tmp_pubkey <<'EOF'
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzLYHwX5Ug/oUObZ5eH5P
rEwmfj4E/YEfSKLgFSsyRGGsVmmjiXBmSbX2s3xbj/ofuvYtkMkP/VPFHy9E/8ox
Y+cRjPzydxz46LPY7jpEw1NHZjOyTeUero5e1nkLhiQqO/cMVYmUnuVcuFfZyZvc
8Apx5fBrIp2oWpF/G9tpUZfUUJaaHiXDtuYP8o8VhYtyjuUu3h7rkQFoMxvuoOFH
6nkc0VQmBsHvCfq4T9v8gyiBtQRy543leapTBMT34mxVIQ4ReGLPVit/6sNLoGLb
gSnGe9Bk/a5V/5vlqeemWF0hgoRtUxMtU1hFbe7e8tSq1j+mu0SHMyKHiHd+OsmU
IQIDAQAB
-----END PUBLIC KEY-----
EOF
echo "${var_signature}" | openssl base64 -d > pia_tmp_sig
printf "%s" "${var_json}" > pia_tmp_json
if ! openssl dgst -sha256 -verify pia_tmp_pubkey -signature pia_tmp_sig pia_tmp_json >/dev/null 2>&1; then
rm -f pia_tmp_sig pia_tmp_json pia_tmp_pubkey
error_exit "Server list signature verification failed"
fi
rm -f pia_tmp_sig pia_tmp_json pia_tmp_pubkey
echo '[*] Server list signature verified'
local var_php vars_region
# PHP code to extract region info
var_php=$(cat <<'EOF'
$r = current(array_filter(json_decode(stream_get_contents(STDIN))->regions, fn($x) => $x->id == "REGION_ID"));
if (!$r) die("ERROR: Region 'REGION_ID' not found\n");
$mt = $r->servers->meta[0];
$wg = $r->servers->wg[0];
echo "region_id=\"REGION_ID\"\n";
echo "region_meta_cn=\"$mt->cn\"\n";
echo "region_meta_ip=\"$mt->ip\"\n";
echo "region_wg_cn=\"$wg->cn\"\n";
echo "region_wg_ip=\"$wg->ip\"\n";
echo "region_wg_port=\"1337\"\n";
EOF
)
var_php=$(echo "${var_php}" | sed "s/REGION_ID/${pia_vpn}/g")
vars_region=$(echo "${var_json}" | php -r "${var_php}")
[ -n "${vars_region}" ] || error_exit "Failed to fetch region info"
printf "%s\n%s\n" "$(grep -v '^region_' pia_config 2>/dev/null || true)" "${vars_region}" > pia_config
echo '[+] Region info ready'
}
get_token() {
echo '[ ] Generating PIA token...'
# Load config
# shellcheck disable=SC1091
[ -f pia_config ] && . ./pia_config
# Skip if token already exists (idempotent)
if [ -n "${token:-}" ]; then
echo '[=] Token already exists'
return 0
fi
# Validate required variables
[ -z "${pia_user:-}" ] && error_exit "pia_user not set"
[ -z "${pia_pass:-}" ] && error_exit "pia_pass not set"
[ -z "${region_meta_cn:-}" ] && error_exit "region_meta_cn not set"
[ -z "${region_meta_ip:-}" ] && error_exit "region_meta_ip not set"
[ -z "${certificate:-}" ] && error_exit "certificate not set"
# Write certificate file (needed by curl)
echo "${certificate}" | openssl base64 -A -d > pia_tmp_cert
[ -s pia_tmp_cert ] || error_exit "Failed to decode certificate"
local var_php var_token
var_php=$(cat <<'EOF'
$d = json_decode(stream_get_contents(STDIN));
if (!$d || ($d->status ?? "") !== "OK") exit(1);
echo $d->token ?? "";
EOF
)
# shellcheck disable=SC2310 # php is a function wrapper for php-cli on FreshTomato
if ! var_token=$(curl --retry 5 --retry-all-errors -Ss -u "${pia_user}:${pia_pass}" --connect-to "${region_meta_cn}::${region_meta_ip}:" --cacert pia_tmp_cert "https://${region_meta_cn}/authv3/generateToken" | php -r "${var_php}"); then
printf "%s\n" "$(grep -v '^token=' pia_config 2>/dev/null || true)" > pia_config
error_exit "Token generation failed"
fi
# Remove certificate file
rm -f pia_tmp_cert
[ -n "${var_token}" ] || error_exit "Failed to parse token"
printf "%s\n%s\n" "$(grep -v '^token=' pia_config 2>/dev/null || true)" "token=\"${var_token}\"" > pia_config
echo '[+] Token ready'
}
gen_peer() {
echo '[ ] Generating peer keys...'
# Load config
# shellcheck disable=SC1091
[ -f pia_config ] && . ./pia_config
# Skip if keys already exist (idempotent)
if [ -n "${peer_prvkey:-}" ] && [ -n "${peer_pubkey:-}" ]; then
echo '[=] Keys already exist'
return 0
fi
# Generate new keys
local var_prvkey var_pubkey
var_prvkey=$(wg genkey)
var_pubkey=$(echo "${var_prvkey}" | wg pubkey)
# Save to config
printf "%s\n%s\n%s\n" "$(grep -v '^peer_' pia_config 2>/dev/null || true)" "peer_prvkey=\"${var_prvkey}\"" "peer_pubkey=\"${var_pubkey}\"" > pia_config
echo '[+] Keys ready'
}
get_auth() {
echo '[ ] Authenticating to PIA...'
# Load config
# shellcheck disable=SC1091
[ -f pia_config ] && . ./pia_config
# Skip if auth already exists (idempotent)
if [ -n "${auth_peer_ip:-}" ] && [ -n "${auth_server_key:-}" ] && [ -n "${auth_server_vip:-}" ]; then
echo '[=] Auth already exists'
return 0
fi
# Validate required variables
[ -z "${region_wg_cn:-}" ] && error_exit "region_wg_cn not set"
[ -z "${region_wg_ip:-}" ] && error_exit "region_wg_ip not set"
[ -z "${region_wg_port:-}" ] && error_exit "region_wg_port not set"
[ -z "${token:-}" ] && error_exit "token not set"
[ -z "${peer_pubkey:-}" ] && error_exit "peer_pubkey not set"
[ -z "${certificate:-}" ] && error_exit "certificate not set"
# Write certificate file (needed by curl)
echo "${certificate}" | openssl base64 -A -d > pia_tmp_cert
[ -s pia_tmp_cert ] || error_exit "Failed to decode certificate"
local var_php vars_auth
# PHP code validates status before parsing
var_php=$(cat <<'EOF'
$d = json_decode(stream_get_contents(STDIN));
if (!$d || ($d->status ?? "") !== "OK") exit(1);
echo "auth_peer_ip=\"$d->peer_ip\"\n";
echo "auth_server_key=\"$d->server_key\"\n";
echo "auth_server_vip=\"$d->server_vip\"\n";
EOF
)
# shellcheck disable=SC2310 # php is a function wrapper for php-cli on FreshTomato
if ! vars_auth=$(curl --retry 10 --retry-all-errors -GSs --connect-to "${region_wg_cn}::${region_wg_ip}:" --cacert pia_tmp_cert --data-urlencode "pt=${token}" --data-urlencode "pubkey=${peer_pubkey}" "https://${region_wg_cn}:${region_wg_port}/addKey" | php -r "${var_php}"); then
printf "%s\n" "$(grep -v '^token=\|^auth_' pia_config 2>/dev/null || true)" > pia_config
error_exit "WireGuard authentication failed"
fi
# Remove certificate file
rm -f pia_tmp_cert
[ -n "${vars_auth}" ] || error_exit "Failed to parse auth response"
printf "%s\n%s\n" "$(grep -v '^auth_' pia_config 2>/dev/null || true)" "${vars_auth}" > pia_config
echo '[+] Auth ready'
}
set_wg() {
echo '[ ] Configuring WireGuard...'
# Load config
# shellcheck disable=SC1091
[ -f pia_config ] && . ./pia_config
# Skip if WireGuard already configured (idempotent)
# shellcheck disable=SC2312 # Piped commands used for state checks, failures expected
if ip link show up | grep -q 'wg0' && \
ip addr show wg0 2>/dev/null | grep -q "${auth_peer_ip:-}" && \
wg show wg0 peers 2>/dev/null | grep -q "^${auth_server_key:-}$" && \
[ "$(wg show wg0 peers 2>/dev/null | wc -l)" -eq 1 ]; then
echo '[=] WireGuard already configured'
return 0
fi
# Validate required variables
[ -z "${peer_prvkey:-}" ] && error_exit "peer_prvkey not set"
[ -z "${auth_server_key:-}" ] && error_exit "auth_server_key not set"
[ -z "${region_wg_ip:-}" ] && error_exit "region_wg_ip not set"
[ -z "${region_wg_port:-}" ] && error_exit "region_wg_port not set"
[ -z "${auth_peer_ip:-}" ] && error_exit "auth_peer_ip not set"
# Write private key file (needed by wg command)
echo "${peer_prvkey}" > pia_tmp_prvkey
# Remove existing peers
# shellcheck disable=SC2312 # Used to check if peers exist, empty output expected
if [ -n "$(wg show wg0 peers 2>/dev/null)" ]; then
echo '[-] Removing existing peers'
for p in $(wg show wg0 peers 2>/dev/null); do wg set wg0 peer "${p}" remove; done
fi
# Configure WireGuard
wg set wg0 fwmark 0xf0b private-key pia_tmp_prvkey peer "${auth_server_key}" endpoint "${region_wg_ip}:${region_wg_port}" persistent-keepalive 25 allowed-ips '0.0.0.0/0'
# Remove private key file
rm -f pia_tmp_prvkey
ip addr flush dev wg0
ip addr replace "${auth_peer_ip}" dev wg0
# Bring up interface with retry (often fails first attempt)
local var_attempt=1 var_backoff=1
while [ "${var_attempt}" -le 5 ]; do
if [ "${var_attempt}" -gt 1 ]; then
echo "[~] Retry ${var_attempt}/5 (backoff: ${var_backoff}s)..."
sleep "${var_backoff}"
var_backoff=$((var_backoff * 2))
fi
ip link set wg0 up && break
var_attempt=$((var_attempt + 1))
done
[ "${var_attempt}" -le 5 ] || error_exit "Failed to bring up wg0 after 5 attempts"
echo '[+] WireGuard ready'
}
set_firewall() {
echo '[ ] Configuring firewall...'
# Check if chains exist and have rules (idempotent)
if iptables -L PIA_INPUT -n >/dev/null 2>&1 && \
iptables -L PIA_FORWARD -n >/dev/null 2>&1 && \
iptables -t nat -L PIA_POSTROUTING -n >/dev/null 2>&1 && \
iptables -t nat -L PIA_POSTROUTING -n 2>/dev/null | grep -q MASQUERADE; then
echo '[=] Firewall already configured'
return 0
fi
# Create custom chains
iptables -N PIA_INPUT 2>/dev/null || true
iptables -N PIA_FORWARD 2>/dev/null || true
iptables -t nat -N PIA_POSTROUTING 2>/dev/null || true
# Flush chains (clean slate)
iptables -F PIA_INPUT
iptables -F PIA_FORWARD
iptables -t nat -F PIA_POSTROUTING
# Hook chains into main chains (remove old hooks first)
iptables -D INPUT -i wg0 -j PIA_INPUT 2>/dev/null || true
iptables -D FORWARD -i wg0 -j PIA_FORWARD 2>/dev/null || true
iptables -D FORWARD -o wg0 -j PIA_FORWARD 2>/dev/null || true
iptables -t nat -D POSTROUTING -o wg0 -j PIA_POSTROUTING 2>/dev/null || true
iptables -I INPUT -i wg0 -j PIA_INPUT
iptables -I FORWARD -i wg0 -j PIA_FORWARD
iptables -I FORWARD -o wg0 -j PIA_FORWARD
iptables -t nat -I POSTROUTING -o wg0 -j PIA_POSTROUTING
# Add rules inside custom chains
iptables -A PIA_INPUT -m state --state NEW -j DROP
iptables -A PIA_FORWARD -i wg0 -m state --state NEW -j DROP
iptables -A PIA_FORWARD -o wg0 -j ACCEPT
iptables -t nat -A PIA_POSTROUTING -j MASQUERADE
echo '[+] Firewall ready'
}
set_routes() {
echo '[ ] Configuring routes...'
# Skip if routes already configured (idempotent)
if ip route show table 1337 | grep -q 'default dev wg0' && ip rule list | grep -q 'not from all fwmark 0xf0b lookup 1337'; then
echo '[=] Routes already configured'
return 0
fi
# Clear custom routing table
echo '[-] Flushing routing table 1337'
ip route flush table 1337
# Add throw routes for all bridge interfaces (LAN prefixes fall through to main table)
local prefix rest
ip -o route show proto kernel | while read -r prefix rest; do
case ${rest} in
*"dev br"*) ip route replace throw "${prefix}" table 1337 && echo "[+] LAN exception added: ${prefix}";;
*) ;;
esac
done
# Set default route through VPN
ip route add default dev wg0 table 1337
# Remove old policy rule if exists
echo '[-] Removing old policy rule'
ip rule del not fwmark 0xf0b table 1337 2>/dev/null || true
# Add policy rule: use table 1337 for all packets NOT marked with 0xf0b
ip rule add not fwmark 0xf0b table 1337
echo '[+] Routes ready'
}
set_bypass() {
echo '[ ] Configuring VPN bypass...'
# Load config
# shellcheck disable=SC1091
[ -f pia_config ] && . ./pia_config
# Load required kernel modules for ipset support
if ! modprobe -a ip_set ip_set_hash_ip xt_set 2>/dev/null; then
echo "[!] WARNING: ipset modules not available, skipping VPN bypass"
logger -t pia_wireguard "WARNING: Skipping VPN bypass"
return 0
fi
# Check if already configured with current IPs (idempotent)
if ipset list pia_bypass >/dev/null 2>&1 && \
iptables -t mangle -L PIA_MANGLE -n >/dev/null 2>&1 && \
iptables -t mangle -L PIA_MANGLE -n 2>/dev/null | grep -q 'match-set pia_bypass'; then
# Verify ipset contains exactly the current bypass IPs
local var_ipset_ips var_current_ips
var_ipset_ips=$(ipset list pia_bypass | grep -E '^[0-9]' | sort)
var_current_ips=$(for ip in ${pia_bypass}; do echo "${ip}"; done | sort)
if [ "${var_ipset_ips}" = "${var_current_ips}" ]; then
echo '[=] VPN bypass already configured'
return 0
fi
echo '[~] Bypass IPs changed, reconfiguring...'
logger -t pia_wireguard "Bypass IPs changed"
fi
# Create or flush ipset
ipset create pia_bypass hash:ip -exist 2>/dev/null
ipset flush pia_bypass
# Add bypass IPs to ipset
for ip in ${pia_bypass}; do
ipset add pia_bypass "${ip}"
done
# Create/clear marking chain (idempotent)
iptables -t mangle -N PIA_MANGLE 2>/dev/null || true
iptables -t mangle -F PIA_MANGLE
# Remove any legacy/ineffective hook (marking after VPN ingress is too late)
iptables -t mangle -D PREROUTING -i wg0 -j PIA_MANGLE 2>/dev/null || true
# PREROUTING for all *non-wg0* ingress
# This covers LAN and any other non-VPN interfaces without knowing their names.
iptables -t mangle -D PREROUTING ! -i wg0 -j PIA_MANGLE 2>/dev/null || true
iptables -t mangle -I PREROUTING ! -i wg0 -j PIA_MANGLE
# OUTPUT for router-originated traffic (local processes)
iptables -t mangle -D OUTPUT -j PIA_MANGLE 2>/dev/null || true
iptables -t mangle -I OUTPUT -j PIA_MANGLE
# Mark rule: packets destined to bypass IPs get the fwmark that *skips* the VPN table
iptables -t mangle -C PIA_MANGLE -m set --match-set pia_bypass dst -j MARK --set-mark 0xf0b 2>/dev/null || \
iptables -t mangle -A PIA_MANGLE -m set --match-set pia_bypass dst -j MARK --set-mark 0xf0b
echo '[+] VPN bypass ready'
}
get_portforward() {
echo '[ ] Requesting port forward...'
# Load config
# shellcheck disable=SC1091
[ -f pia_config ] && . ./pia_config
# Skip if port forward already exists (idempotent)
if [ -n "${portforward_port:-}" ] && [ -n "${portforward_signature:-}" ] && [ -n "${portforward_payload:-}" ]; then
echo '[=] Port forward already exists'
return 0
fi
# Validate required variables
[ -z "${region_wg_cn:-}" ] && error_exit "region_wg_cn not set"
[ -z "${auth_server_vip:-}" ] && error_exit "auth_server_vip not set"
[ -z "${token:-}" ] && error_exit "token not set"
[ -z "${certificate:-}" ] && error_exit "certificate not set"
# Write certificate file (needed by curl)
echo "${certificate}" | openssl base64 -A -d > pia_tmp_cert
[ -s pia_tmp_cert ] || error_exit "Failed to decode certificate"
# Request port forward signature
local var_php vars_portforward
var_php=$(cat <<'EOF'
$d = json_decode(stream_get_contents(STDIN));
if (!$d || ($d->status ?? "") !== "OK") exit(1);
echo "portforward_signature=\"$d->signature\"\n";
echo "portforward_payload=\"$d->payload\"\n";
echo "portforward_port=\"" . json_decode(base64_decode($d->payload))->port . "\"\n";
EOF
)
# shellcheck disable=SC2310 # php is a function wrapper for php-cli on FreshTomato
if ! vars_portforward=$(curl --retry 10 --retry-all-errors -GSs --connect-to "${region_wg_cn}::${auth_server_vip}:" --cacert pia_tmp_cert --data-urlencode "token=${token}" "https://${region_wg_cn}:19999/getSignature" --interface wg0 | php -r "${var_php}"); then
printf "%s\n" "$(grep -v '^portforward_' pia_config 2>/dev/null || true)" > pia_config
error_exit "Port forward signature failed"
fi
# Remove certificate file
rm -f pia_tmp_cert
[ -n "${vars_portforward}" ] || error_exit "Failed to parse port forward response"
# Save to config
printf "%s\n%s\n" "$(grep -v '^portforward_' pia_config 2>/dev/null || true)" "${vars_portforward}" > pia_config
echo '[+] Port forward ready'
}
set_portforward() {
echo '[ ] Configuring port forward NAT...'
# Load config
# shellcheck disable=SC1091
[ -f pia_config ] && . ./pia_config
# Validate required variables
[ -z "${region_wg_cn:-}" ] && error_exit "region_wg_cn not set"
[ -z "${auth_server_vip:-}" ] && error_exit "auth_server_vip not set"
[ -z "${portforward_signature:-}" ] && error_exit "portforward_signature not set"
[ -z "${portforward_payload:-}" ] && error_exit "portforward_payload not set"
[ -z "${portforward_port:-}" ] && error_exit "portforward_port not set"
[ -z "${pia_pf:-}" ] && error_exit "pia_pf not set"
[ -z "${certificate:-}" ] && error_exit "certificate not set"
# Validate pia_pf format (must be IP:PORT)
echo "${pia_pf}" | grep -q '^[0-9.]\+:[0-9]\+$' || error_exit "pia_pf must be in format IP:PORT (e.g., 192.168.1.10:2022)"
# Write certificate file (needed by curl)
echo "${certificate}" | openssl base64 -A -d > pia_tmp_cert
[ -s pia_tmp_cert ] || error_exit "Failed to decode certificate"
# Bind port with PIA (always refresh binding)
local var_bind_response var_bind_status var_bind_message
var_bind_response=$(curl -sGm 5 --connect-to "${region_wg_cn}::${auth_server_vip}:" --cacert pia_tmp_cert --data-urlencode "payload=${portforward_payload}" --data-urlencode "signature=${portforward_signature}" "https://${region_wg_cn}:19999/bindPort" --interface wg0)
# Remove certificate file
rm -f pia_tmp_cert
# Parse response
var_bind_status=$(echo "${var_bind_response}" | php -r 'echo json_decode(stream_get_contents(STDIN))->status ?? "";')
var_bind_message=$(echo "${var_bind_response}" | php -r 'echo json_decode(stream_get_contents(STDIN))->message ?? "";')
if [ "${var_bind_status}" = "OK" ]; then
echo "[*] Port binding: ${var_bind_message}"
else
echo "[!] WARNING: Port bind failed: ${var_bind_response}"
logger -t pia_wireguard "WARNING: Port bind failed"
fi
# Parse IP and port from pia_pf
local var_pf_ip="${pia_pf%:*}" var_pf_port="${pia_pf#*:}"
# Check if already configured (idempotent)
if iptables -t nat -L PIA_NAT -n >/dev/null 2>&1 && \
iptables -L PIA_PORTFORWARD -n >/dev/null 2>&1; then
# Verify configuration matches current pia_pf
if [ "${var_pf_ip}" = "0.0.0.0" ]; then
# Check for REDIRECT
if iptables -t nat -L PIA_NAT -n 2>/dev/null | grep -q "redir ports ${var_pf_port}"; then
echo '[=] Port forward already configured'
return 0
fi
else
# Check for DNAT to current destination
if iptables -t nat -L PIA_NAT -n 2>/dev/null | grep -q "to:${pia_pf}"; then
echo '[=] Port forward already configured'
return 0
fi
fi
echo '[~] Port forward configuration changed, reconfiguring...'
fi
# Create custom chains
iptables -t nat -N PIA_NAT 2>/dev/null || true
iptables -N PIA_PORTFORWARD 2>/dev/null || true
# Flush chains (clean slate)
iptables -t nat -F PIA_NAT
iptables -F PIA_PORTFORWARD
# Hook chains into main chains (remove old hooks first)
iptables -t nat -D PREROUTING -i wg0 -j PIA_NAT 2>/dev/null || true
iptables -D INPUT -i wg0 -j PIA_PORTFORWARD 2>/dev/null || true
iptables -D FORWARD -i wg0 -j PIA_PORTFORWARD 2>/dev/null || true
iptables -t nat -I PREROUTING -i wg0 -j PIA_NAT
iptables -I INPUT -i wg0 -j PIA_PORTFORWARD
iptables -I FORWARD -i wg0 -j PIA_PORTFORWARD
# Add rules based on mode
if [ "${var_pf_ip}" = "0.0.0.0" ]; then
# Router mode - REDIRECT and INPUT
iptables -t nat -A PIA_NAT -p tcp --dport "${portforward_port}" -j REDIRECT --to-ports "${var_pf_port}"
iptables -A PIA_PORTFORWARD -p tcp --dport "${var_pf_port}" -m state --state NEW -j ACCEPT
echo "[+] Port ${portforward_port} redirected to router port ${var_pf_port}"
else
# Forward mode - DNAT and FORWARD
iptables -t nat -A PIA_NAT -p tcp --dport "${portforward_port}" -j DNAT --to-destination "${pia_pf}"
iptables -A PIA_PORTFORWARD -m state --state NEW,RELATED,ESTABLISHED -d "${var_pf_ip}" -p tcp --dport "${var_pf_port}" -j ACCEPT
echo "[+] Port ${portforward_port} forwarded to ${pia_pf}"
fi
}
set_duckdns() {
echo '[ ] Updating DuckDNS...'
# Load config
# shellcheck disable=SC1091
[ -f pia_config ] && . ./pia_config
# Validate and split pia_duckdns (format: domain:token)
[ -z "${pia_duckdns:-}" ] && error_exit "pia_duckdns not set"
echo "${pia_duckdns}" | grep -q ':' || error_exit "pia_duckdns must be in format DOMAIN:TOKEN"
local var_domain var_token
var_domain="${pia_duckdns%%:*}"
var_token="${pia_duckdns#*:}"
[ -z "${var_domain}" ] && error_exit "DuckDNS domain is empty"
[ -z "${var_token}" ] && error_exit "DuckDNS token is empty"
[ -z "${portforward_port:-}" ] && error_exit "portforward_port not set (port forwarding must be enabled)"
[ -z "${region_wg_ip:-}" ] && error_exit "region_wg_ip not set"
# Update DuckDNS A record (IP address)
local var_response_ip
var_response_ip=$(curl -sSGm 5 "https://www.duckdns.org/update?domains=${var_domain}&token=${var_token}&ip=${region_wg_ip}" 2>&1) || error_exit "DuckDNS IP update failed: ${var_response_ip}"
[ "${var_response_ip}" = "OK" ] || error_exit "DuckDNS IP update failed: ${var_response_ip}"
# Update DuckDNS TXT record (port number)
local var_response_txt
var_response_txt=$(curl -sSGm 5 "https://www.duckdns.org/update?domains=${var_domain}&token=${var_token}&txt=${portforward_port}" 2>&1) || error_exit "DuckDNS TXT update failed: ${var_response_txt}"
[ "${var_response_txt}" = "OK" ] || error_exit "DuckDNS TXT update failed: ${var_response_txt}"
echo "[+] DNS records updated: ${var_domain}.duckdns.org A=${region_wg_ip} TXT=${portforward_port}"
}
logger -t pia_wireguard "PIA WireGuard script started"
init_script
init_module
get_cert
# shellcheck disable=SC2310
if ! healthcheck_tunnel; then
logger -t pia_wireguard "WARNING: Tunnel unhealthy, rebuilding VPN"
printf "%s\n" "$(grep -v '^region_\|^token=\|^auth_\|^peer_' pia_config 2>/dev/null || true)" > pia_config
fi
get_region
get_token
gen_peer
get_auth
set_wg
set_firewall
set_routes
# shellcheck disable=SC2310
healthcheck_tunnel || error_exit "Tunnel healthcheck failed"
if [ "${pia_bypass:-false}" != 'false' ]; then
set_bypass
fi
if [ "${pia_pf:-false}" != 'false' ]; then
get_portforward
set_portforward
if [ "${pia_duckdns:-false}" != 'false' ]; then
set_duckdns
fi
fi
### OPTIONAL
### Force wireguard traffic over specific interface (this is just an example)
# if ! ip route show | grep -q "${region_wg_ip}/32 via 10.71.0.1 dev wlp2s0 metric 50"; then
# ip route list metric 50 | while read -r r; do ip route del "${r}"; done
# ip route add ${region_wg_ip}/32 via 10.71.0.1 dev wlp2s0 metric 50
# fi
logger -t pia_wireguard "PIA WireGuard script completed"
@rveznaver
Copy link

Just a quick comment to let you know this script has been significantly updated and refactored at https://github.com/rveznaver/pia-freshtomato. It is technically five times longer, but has more features and is more performant.

@pedro0311
Copy link
Author

Thanks for the info!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment