Unraid PIA WireGuard wg0 Auto-Refresh Setup¶
Purpose¶
This setup automatically refreshes Unraid built-in WireGuard tunnel wg0 when the PIA VPN tunnel loses internet connectivity.
The PIA config generator runs inside a Docker container using wg1, because the ISP blocks the PIA generator script when run from the normal ISP connection.
Final behavior¶
wg0 has internet
do nothing
wg0 has no internet
watchdog increases failure counter
wg0 fails 3 consecutive checks
watchdog creates:
/boot/config/wireguard/pia-refresh.request
Docker container using wg1 detects request
runs:
python3 pia_wg_gen.py
Docker writes generated config:
/boot/config/wireguard/wg0.generated
Next watchdog run
reads wg0.generated
extracts only:
Interface PrivateKey
Interface Address
Peer PublicKey
Peer Endpoint
patches:
/boot/config/wireguard/wg0.conf
preserves:
PostUp
PostDown
AllowedIPs
PersistentKeepalive
restarts wg0
deletes pending files
````
## Important files
```text
/boot/config/wireguard/wg0.conf
Main Unraid WireGuard tunnel config.
/boot/config/wireguard/wg0.generated
Temporary generated PIA config waiting to be applied.
/boot/config/wireguard/pia-refresh.request
Trigger file. When this exists, the Docker worker generates a new PIA config.
/boot/config/wireguard/pia-watchdog.state
Failure counter file.
/boot/config/wireguard/unraid_wg0_watchdog.sh
Main Unraid host watchdog script.
/mnt/user/appdata/pia-wg-refresh/
Docker appdata folder for the PIA refresh worker.
1. Folder structure¶
Create the appdata folder:
mkdir -p /mnt/user/appdata/pia-wg-refresh
Place the PIA generator script here:
/mnt/user/appdata/pia-wg-refresh/pia_wg_gen.py
Confirm:
ls -l /mnt/user/appdata/pia-wg-refresh/pia_wg_gen.py
2. Docker worker script¶
Create:
nano /mnt/user/appdata/pia-wg-refresh/pia_refresh_worker.py
Paste:
#!/usr/bin/env python3
import configparser
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
BASE_DIR = Path(os.getenv("BASE_DIR", "/app"))
REQUEST_FILE = Path(os.getenv("REQUEST_FILE", "/wireguard/pia-refresh.request"))
PENDING_FILE = Path(os.getenv("PENDING_FILE", "/wireguard/wg0.generated"))
OUTPUT_CONF = Path(os.getenv("OUTPUT_CONF", str(BASE_DIR / "wg0.conf")))
GEN_CMD = os.getenv("GEN_CMD", "python3 pia_wg_gen.py")
POLL_SECONDS = int(os.getenv("POLL_SECONDS", "30"))
REQUIRED_INTERFACE_KEYS = ("PrivateKey", "Address")
REQUIRED_PEER_KEYS = ("PublicKey", "Endpoint")
def load_wg_config(path: Path) -> configparser.ConfigParser:
parser = configparser.ConfigParser(strict=False, interpolation=None)
parser.optionxform = str
with path.open("r", encoding="utf-8") as handle:
parser.read_file(handle)
return parser
def validate_generated_config(path: Path) -> None:
parser = load_wg_config(path)
if "Interface" not in parser or "Peer" not in parser:
raise ValueError("Generated config must contain [Interface] and [Peer].")
for key in REQUIRED_INTERFACE_KEYS:
value = parser["Interface"].get(key, "").strip()
if not value:
raise ValueError(f"Generated config missing [Interface] {key}.")
for key in REQUIRED_PEER_KEYS:
value = parser["Peer"].get(key, "").strip()
if not value:
raise ValueError(f"Generated config missing [Peer] {key}.")
endpoint = parser["Peer"].get("Endpoint", "").strip()
if ":" not in endpoint:
raise ValueError("Generated [Peer] Endpoint must include host:port.")
def run_generator() -> None:
if OUTPUT_CONF.exists():
OUTPUT_CONF.unlink()
result = subprocess.run(
GEN_CMD,
cwd=str(BASE_DIR),
shell=True,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=180,
)
if result.returncode != 0:
raise RuntimeError(
f"Generator failed with exit code {result.returncode}.\n"
f"STDOUT:\n{result.stdout}\n"
f"STDERR:\n{result.stderr}"
)
if not OUTPUT_CONF.exists():
stdout_text = result.stdout.strip()
if "[Interface]" in stdout_text and "[Peer]" in stdout_text:
OUTPUT_CONF.write_text(stdout_text + "\n", encoding="utf-8")
else:
raise FileNotFoundError(
f"Generator did not create {OUTPUT_CONF} and stdout did not contain wg config."
)
validate_generated_config(OUTPUT_CONF)
temp_pending = PENDING_FILE.with_suffix(".generated.tmp")
shutil.copyfile(OUTPUT_CONF, temp_pending)
os.replace(temp_pending, PENDING_FILE)
REQUEST_FILE.unlink(missing_ok=True)
print(f"Created pending config: {PENDING_FILE}", flush=True)
def main() -> int:
print("PIA refresh worker started.", flush=True)
while True:
try:
if REQUEST_FILE.exists() and not PENDING_FILE.exists():
print("Refresh request detected.", flush=True)
run_generator()
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr, flush=True)
time.sleep(POLL_SECONDS)
if __name__ == "__main__":
raise SystemExit(main())
Validate syntax:
python3 -m py_compile /mnt/user/appdata/pia-wg-refresh/pia_refresh_worker.py
3. Dockerfile¶
Create:
nano /mnt/user/appdata/pia-wg-refresh/Dockerfile
Paste:
FROM python:3.12-alpine
WORKDIR /app
ENV BASE_DIR=/app \
REQUEST_FILE=/wireguard/pia-refresh.request \
PENDING_FILE=/wireguard/wg0.generated \
OUTPUT_CONF=/app/wg0.conf \
GEN_CMD="python3 pia_wg_gen.py" \
POLL_SECONDS=30
RUN apk add --no-cache \
curl \
ca-certificates \
wireguard-tools \
iproute2 \
bash \
&& python3 -m pip install --no-cache-dir --upgrade pip \
&& python3 -m pip install --no-cache-dir cryptography
COPY pia_refresh_worker.py /app/pia_refresh_worker.py
COPY pia_wg_gen.py /app/pia_wg_gen.py
RUN chmod 700 /app/pia_refresh_worker.py /app/pia_wg_gen.py
CMD ["python3", "/app/pia_refresh_worker.py"]
Build image:
docker build --no-cache -t pia-wg-refresh:latest /mnt/user/appdata/pia-wg-refresh
4. Docker container¶
Create the container from terminal:
docker run -d \
--name pia-wg-refresh \
--restart unless-stopped \
--network wg1 \
-v /boot/config/wireguard:/wireguard:rw \
-v /mnt/user/appdata/pia-wg-refresh:/app:rw \
pia-wg-refresh:latest
Check logs:
docker logs -f pia-wg-refresh
Expected:
PIA refresh worker started.
Check dependencies:
docker exec -it pia-wg-refresh sh
Inside container:
python3 -c "import cryptography; print('cryptography OK')"
curl --version
exit
Expected:
cryptography OK
5. Unraid watchdog script¶
Create:
nano /boot/config/wireguard/unraid_wg0_watchdog.sh
Paste:
#!/bin/bash
set -u
WG_NAME="wg0"
WG_FILE="/boot/config/wireguard/wg0.conf"
WIREGUARD_DIR="/boot/config/wireguard"
GENERATED_FILE="$WIREGUARD_DIR/wg0.generated"
REQUEST_FILE="$WIREGUARD_DIR/pia-refresh.request"
STATE_FILE="$WIREGUARD_DIR/pia-watchdog.state"
LOCK_DIR="/tmp/pia-wg0-watchdog.lock"
FAIL_LIMIT=3
PING_TARGET="1.1.1.1"
GENERATED_WAIT_SECONDS=120
GENERATED_WAIT_INTERVAL=5
log() {
logger -t pia-wg0-watchdog -- "$*"
echo "$*"
}
cleanup() {
rmdir "$LOCK_DIR" 2>/dev/null || true
}
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
log "Another watchdog run is active. Exiting."
exit 0
fi
trap cleanup EXIT
read_generated_value() {
local section="$1"
local key="$2"
awk -v section="$section" -v key="$key" '
BEGIN { in_section=0 }
/^[[:space:]]*\[/ {
in_section = ($0 ~ "^[[:space:]]*\\[" section "\\]")
}
in_section == 1 {
line=$0
sub(/#.*/, "", line)
split(line, parts, "=")
left=parts[1]
gsub(/^[[:space:]]+|[[:space:]]+$/, "", left)
if (left == key) {
value=substr(line, index(line, "=") + 1)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
print value
exit
}
}
' "$GENERATED_FILE"
}
patch_wg0_config() {
local private_key address public_key endpoint tmp_file backup_file ts
private_key="$(read_generated_value Interface PrivateKey)"
address="$(read_generated_value Interface Address)"
public_key="$(read_generated_value Peer PublicKey)"
endpoint="$(read_generated_value Peer Endpoint)"
if [ -z "$private_key" ] || [ -z "$address" ] || [ -z "$public_key" ] || [ -z "$endpoint" ]; then
log "Generated config missing one or more required values. Refusing to patch."
return 1
fi
if [[ "$endpoint" != *:* ]]; then
log "Generated Endpoint is invalid: $endpoint"
return 1
fi
ts="$(date +%Y%m%d-%H%M%S)"
backup_file="$WIREGUARD_DIR/wg0.conf.backup.$ts"
tmp_file="$WIREGUARD_DIR/wg0.conf.tmp.$ts"
cp -p "$WG_FILE" "$backup_file"
awk \
-v private_key="$private_key" \
-v address="$address" \
-v public_key="$public_key" \
-v endpoint="$endpoint" '
BEGIN { section="" }
/^[[:space:]]*\[Interface\][[:space:]]*$/ {
section="Interface"
print
next
}
/^[[:space:]]*\[Peer\][[:space:]]*$/ {
section="Peer"
print
next
}
/^[[:space:]]*\[/ {
section="Other"
print
next
}
section == "Interface" && $0 ~ /^[[:space:]]*PrivateKey[[:space:]]*=/ {
print "PrivateKey=" private_key
next
}
section == "Interface" && $0 ~ /^[[:space:]]*Address[[:space:]]*=/ {
print "Address=" address
next
}
section == "Peer" && $0 ~ /^[[:space:]]*PublicKey[[:space:]]*=/ {
print "PublicKey=" public_key
next
}
section == "Peer" && $0 ~ /^[[:space:]]*Endpoint[[:space:]]*=/ {
print "Endpoint=" endpoint
next
}
{ print }
' "$WG_FILE" > "$tmp_file"
if ! grep -q '^PrivateKey=' "$tmp_file" || \
! grep -q '^Address=' "$tmp_file" || \
! grep -q '^PublicKey=' "$tmp_file" || \
! grep -q '^Endpoint=' "$tmp_file"; then
log "Patched config validation failed. Keeping existing config."
rm -f "$tmp_file"
return 1
fi
mv "$tmp_file" "$WG_FILE"
chmod 600 "$WG_FILE"
log "Patched $WG_FILE. Backup: $backup_file"
}
restart_wg0() {
if [ -x /etc/rc.d/rc.wireguard ]; then
/etc/rc.d/rc.wireguard stop "$WG_NAME" || true
sleep 2
/etc/rc.d/rc.wireguard start "$WG_NAME" && return 0
fi
if command -v wg-quick >/dev/null 2>&1; then
wg-quick down "$WG_NAME" || true
sleep 2
wg-quick up "$WG_FILE" && return 0
fi
log "No supported WireGuard restart command found."
return 1
}
wg0_has_internet() {
ping -I "$WG_NAME" -c 2 -W 3 "$PING_TARGET" >/dev/null 2>&1
}
apply_generated_if_present() {
if [ ! -f "$GENERATED_FILE" ]; then
return 1
fi
log "Generated config found. Applying to $WG_FILE."
if patch_wg0_config && restart_wg0; then
rm -f "$GENERATED_FILE" "$REQUEST_FILE" "$STATE_FILE"
log "wg0 refresh completed."
return 0
fi
log "wg0 refresh failed. Generated file retained: $GENERATED_FILE"
return 1
}
request_refresh_and_wait() {
local waited
if [ ! -f "$REQUEST_FILE" ]; then
date -Iseconds > "$REQUEST_FILE"
log "Created refresh request: $REQUEST_FILE"
else
log "Refresh request already exists: $REQUEST_FILE"
fi
waited=0
while [ "$waited" -lt "$GENERATED_WAIT_SECONDS" ]; do
if [ -f "$GENERATED_FILE" ]; then
log "Generated config detected after ${waited}s. Applying now."
apply_generated_if_present
return $?
fi
sleep "$GENERATED_WAIT_INTERVAL"
waited=$((waited + GENERATED_WAIT_INTERVAL))
done
log "Timed out waiting for generated config after ${GENERATED_WAIT_SECONDS}s. It will be applied on next run if created."
return 1
}
apply_generated_if_present && exit 0
fail_count=0
if [ -f "$STATE_FILE" ]; then
fail_count="$(cat "$STATE_FILE" 2>/dev/null || echo 0)"
fi
if ! [[ "$fail_count" =~ ^[0-9]+$ ]]; then
fail_count=0
fi
if wg0_has_internet; then
echo 0 > "$STATE_FILE"
log "wg0 health OK."
exit 0
fi
fail_count=$((fail_count + 1))
echo "$fail_count" > "$STATE_FILE"
log "wg0 health failed. Consecutive failures: $fail_count/$FAIL_LIMIT"
if [ "$fail_count" -ge "$FAIL_LIMIT" ]; then
request_refresh_and_wait
fi
Make executable:
chmod 700 /boot/config/wireguard/unraid_wg0_watchdog.sh
Validate syntax:
bash -n /boot/config/wireguard/unraid_wg0_watchdog.sh
6. Manual health test¶
Run:
ping -I wg0 -c 3 -W 3 1.1.1.1
echo $?
Expected:
0
Run curl through wg0:
curl --interface wg0 -4 --connect-timeout 10 https://ifconfig.me
echo $?
Expected:
0
Run watchdog manually:
/boot/config/wireguard/unraid_wg0_watchdog.sh
Expected if wg0 is healthy:
wg0 health OK.
7. Manual full refresh test¶
Clean old files:
rm -f /boot/config/wireguard/pia-refresh.request
rm -f /boot/config/wireguard/wg0.generated
rm -f /boot/config/wireguard/pia-watchdog.state
Create manual request:
date -Iseconds > /boot/config/wireguard/pia-refresh.request
Check Docker logs:
docker logs -f pia-wg-refresh
Expected:
Refresh request detected.
Created pending config: /wireguard/wg0.generated
Stop logs:
CTRL+C
Confirm pending config exists:
ls -l /boot/config/wireguard/wg0.generated
Apply generated config:
/boot/config/wireguard/unraid_wg0_watchdog.sh
Expected:
Generated config found. Applying to /boot/config/wireguard/wg0.conf.
Patched /boot/config/wireguard/wg0.conf. Backup: /boot/config/wireguard/wg0.conf.backup.YYYYMMDD-HHMMSS
wg0 refresh completed.
Confirm cleanup:
ls -l /boot/config/wireguard/pia-refresh.request /boot/config/wireguard/wg0.generated 2>/dev/null
Expected:
No output
Confirm wg0 works:
ping -I wg0 -c 3 -W 3 1.1.1.1
wg show wg0
8. Unraid User Scripts schedule¶
Only schedule this script:
/boot/config/wireguard/unraid_wg0_watchdog.sh
Do not schedule:
python3 pia_wg_gen.py
Do not schedule the Docker worker. The Docker worker must only stay running.
In Unraid:
- Go to Settings.
- Open User Scripts.
- Click Add New Script.
- Name:
wg0 PIA Watchdog
- Edit script.
- Paste:
#!/bin/bash
/boot/config/wireguard/unraid_wg0_watchdog.sh
- Save.
- Click Run Script manually first.
Expected:
wg0 health OK.
- Set schedule to custom cron:
* * * * *
This runs every 1 minute.
9. Timing behavior¶
With:
FAIL_LIMIT=3
And schedule:
* * * * *
Expected recovery flow:
Minute 1:
wg0 check failed
Consecutive failures: 1/3
Minute 2:
wg0 check failed
Consecutive failures: 2/3
Minute 3:
wg0 check failed
Consecutive failures: 3/3
creates pia-refresh.request
Docker worker:
generates wg0.generated
Minute 4:
watchdog sees wg0.generated
patches wg0.conf
restarts wg0
deletes request/generated files
Approximate recovery time:
3 to 4 minutes
This is intentional to avoid unnecessary refresh from one temporary ping failure.
10. Normal status check¶
Run:
cat /boot/config/wireguard/pia-watchdog.state 2>/dev/null
ls -l /boot/config/wireguard/pia-refresh.request /boot/config/wireguard/wg0.generated 2>/dev/null
Normal healthy result:
0
No pending files should show.
11. Logs¶
Check watchdog logs:
grep pia-wg0-watchdog /var/log/syslog
Check Docker worker logs:
docker logs --tail 50 pia-wg-refresh
Live Docker logs:
docker logs -f pia-wg-refresh
12. Rollback¶
List backups:
ls -lh /boot/config/wireguard/wg0.conf.backup.*
Restore one backup:
cp -p /boot/config/wireguard/wg0.conf.backup.YYYYMMDD-HHMMSS /boot/config/wireguard/wg0.conf
chmod 600 /boot/config/wireguard/wg0.conf
Restart wg0:
/etc/rc.d/rc.wireguard stop wg0
sleep 2
/etc/rc.d/rc.wireguard start wg0
If that does not work:
wg-quick down wg0
sleep 2
wg-quick up /boot/config/wireguard/wg0.conf
13. Rebuild container after script changes¶
If pia_wg_gen.py, pia_refresh_worker.py, or Dockerfile changes:
docker stop pia-wg-refresh 2>/dev/null
docker rm pia-wg-refresh 2>/dev/null
docker build --no-cache -t pia-wg-refresh:latest /mnt/user/appdata/pia-wg-refresh
docker run -d \
--name pia-wg-refresh \
--restart unless-stopped \
--network wg1 \
-v /boot/config/wireguard:/wireguard:rw \
-v /mnt/user/appdata/pia-wg-refresh:/app:rw \
pia-wg-refresh:latest
14. Safety notes¶
The Docker container has write access to:
/boot/config/wireguard
But it does not directly restart wg0.
Only the Unraid host watchdog script patches and restarts wg0.
This is safer than giving the container:
/var/run/docker.sock
or full host privileges.
The automation only replaces these values in wg0.conf:
[Interface]
PrivateKey
Address
[Peer]
PublicKey
Endpoint
It does not replace:
PostUp
PostDown
AllowedIPs
PersistentKeepalive
DNS