stack/scripts/gluetun-endpoint-watch.sh
tim 218e64314d Add gluetun endpoint watcher (5-minute systemd timer)
When gluetun is unhealthy, re-dig the WireGuard peer hostname, update
wg0.conf Endpoint, recreate gluetun then qbittorrent, and verify the
VPN exit IP. Keeps torrent path alive when provider endpoints rotate.
2026-07-22 11:18:39 -07:00

168 lines
4.9 KiB
Bash
Executable file

#!/usr/bin/env bash
# If gluetun is unhealthy/down, re-resolve the WireGuard peer hostname,
# update wireguard/wg0.conf Endpoint, recreate gluetun, then qbittorrent + test.
#
# Install: systemd timer (see ansible stack role) or cron every 5 minutes.
# Config: STACK_DIR, WIREGUARD_ENDPOINT_HOST, WIREGUARD_CONF (or .env in STACK_DIR)
set -euo pipefail
STACK_DIR="${STACK_DIR:-/opt/stack}"
cd "$STACK_DIR"
# Load .env if present (ignore errors / comments)
if [[ -f "$STACK_DIR/.env" ]]; then
set -a
# shellcheck disable=SC1091
source <(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$STACK_DIR/.env" | sed 's/\r$//') || true
set +a
fi
WIREGUARD_CONF="${WIREGUARD_CONF:-$STACK_DIR/wireguard/wg0.conf}"
WIREGUARD_ENDPOINT_HOST="${WIREGUARD_ENDPOINT_HOST:-}"
WIREGUARD_ENDPOINT_PORT="${WIREGUARD_ENDPOINT_PORT:-443}"
COMPOSE="${COMPOSE:-docker compose}"
LOG_TAG="gluetun-endpoint-watch"
HEALTH_WAIT_SECS="${HEALTH_WAIT_SECS:-90}"
log() { echo "$(date -Iseconds) [$LOG_TAG] $*"; }
require_cmd() {
command -v "$1" >/dev/null 2>&1 || { log "ERROR: missing command: $1"; exit 1; }
}
require_cmd docker
require_cmd dig
require_cmd sed
if [[ ! -f "$WIREGUARD_CONF" ]]; then
log "ERROR: WireGuard config not found: $WIREGUARD_CONF"
exit 1
fi
# Hostname: env first, else comment line in wg0.conf:
# # EndpointHost = las-183-wg.example.com
if [[ -z "$WIREGUARD_ENDPOINT_HOST" ]]; then
WIREGUARD_ENDPOINT_HOST="$(
sed -n 's/^[[:space:]]*#[[:space:]]*EndpointHost[[:space:]]*=[[:space:]]*//p' "$WIREGUARD_CONF" | head -1 | tr -d '[:space:]'
)"
fi
if [[ -z "$WIREGUARD_ENDPOINT_HOST" ]]; then
log "ERROR: set WIREGUARD_ENDPOINT_HOST in .env or add '# EndpointHost = hostname' to $WIREGUARD_CONF"
exit 1
fi
gluetun_status() {
# returns: healthy | unhealthy | starting | none | exited | ...
local s
s="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' gluetun 2>/dev/null || true)"
if [[ -z "$s" ]]; then
echo "none"
else
echo "$s"
fi
}
current_endpoint_ip() {
sed -n 's/^[[:space:]]*Endpoint[[:space:]]*=[[:space:]]*\([0-9.]*\):.*/\1/p' "$WIREGUARD_CONF" | head -1
}
resolve_endpoint_ip() {
# Prefer A record; take first stable answer
dig +short +time=3 +tries=2 A "$WIREGUARD_ENDPOINT_HOST" 2>/dev/null \
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' \
| head -1
}
update_endpoint() {
local new_ip="$1"
local port="$2"
local tmp
tmp="$(mktemp)"
# shellcheck disable=SC2016
sed -E "s|^[[:space:]]*Endpoint[[:space:]]*=.*|Endpoint = ${new_ip}:${port}|" "$WIREGUARD_CONF" >"$tmp"
if ! grep -qE '^[[:space:]]*PersistentKeepalive' "$tmp"; then
# Append keepalive under [Peer] if missing
if grep -qE '^[[:space:]]*\[Peer\]' "$tmp"; then
awk -v p="PersistentKeepalive = 25" '
/^\[Peer\]/ { inpeer=1 }
inpeer && /^\[/ && !/^\[Peer\]/ { if (!done) { print p; done=1 } inpeer=0 }
{ print }
END { if (inpeer && !done) print p }
' "$tmp" >"${tmp}.2"
mv "${tmp}.2" "$tmp"
fi
fi
# Ensure EndpointHost comment preserved / written for next runs
if ! grep -qE '^[[:space:]]*#[[:space:]]*EndpointHost' "$tmp"; then
sed -i "1a # EndpointHost = ${WIREGUARD_ENDPOINT_HOST}" "$tmp"
fi
cat "$tmp" >"$WIREGUARD_CONF"
rm -f "$tmp"
}
wait_gluetun_healthy() {
local i
for ((i = 1; i <= HEALTH_WAIT_SECS; i++)); do
if [[ "$(gluetun_status)" == "healthy" ]]; then
log "gluetun is healthy"
return 0
fi
sleep 1
done
log "ERROR: gluetun not healthy after ${HEALTH_WAIT_SECS}s (status=$(gluetun_status))"
return 1
}
test_vpn_exit() {
local ip
ip="$(docker exec qbittorrent curl -sS --max-time 15 ifconfig.me 2>/dev/null || true)"
if [[ -z "$ip" ]]; then
ip="$(docker exec gluetun wget -qO- --timeout=15 https://ifconfig.me/ip 2>/dev/null || true)"
fi
if [[ -n "$ip" ]]; then
log "VPN exit IP: $ip"
return 0
fi
log "ERROR: could not fetch public IP through VPN"
return 1
}
# --- main ---
status="$(gluetun_status)"
log "gluetun status=$status host=$WIREGUARD_ENDPOINT_HOST conf=$WIREGUARD_CONF"
if [[ "$status" == "healthy" ]]; then
log "OK — nothing to do"
exit 0
fi
log "gluetun not healthy — resolving $WIREGUARD_ENDPOINT_HOST"
new_ip="$(resolve_endpoint_ip || true)"
if [[ -z "$new_ip" ]]; then
log "ERROR: dig returned no A record for $WIREGUARD_ENDPOINT_HOST"
exit 1
fi
old_ip="$(current_endpoint_ip || true)"
log "Endpoint DNS: $new_ip (config had: ${old_ip:-none})"
if [[ "$new_ip" != "$old_ip" ]]; then
log "Updating Endpoint to ${new_ip}:${WIREGUARD_ENDPOINT_PORT}"
update_endpoint "$new_ip" "$WIREGUARD_ENDPOINT_PORT"
else
log "Endpoint IP unchanged; still recreating gluetun (tunnel may be wedged)"
fi
log "Recreating gluetun"
$COMPOSE up -d gluetun --force-recreate --remove-orphans
wait_gluetun_healthy
log "Recreating qbittorrent (last step before test)"
$COMPOSE up -d qbittorrent --force-recreate
# qbittorrent waits on gluetun healthy via depends_on
sleep 3
log "Testing VPN path"
test_vpn_exit
log "Done"