Enhance gluetun PIA WireGuard rotator with health check and recovery logic. Introduce new environment variables for health check interval and unhealthy cooldown. Refactor entrypoint.py to manage Gluetun health status and implement two-step recovery for unhealthy states. Update rotation logic to account for health status during scheduled rotations.
Build and Push Docker Images / build-and-push (push) Successful in 51s

This commit is contained in:
2026-08-16 18:47:29 +02:00
parent f5b9a7fa21
commit 7d6405c776
4 changed files with 310 additions and 147 deletions
@@ -24,5 +24,7 @@ ENV TOKEN_CACHE_PATH=/config/cache/pia-token.json
ENV TOKEN_CACHE_TTL=20h ENV TOKEN_CACHE_TTL=20h
ENV PIA_CA_PATH=/config/cache/ca.rsa.4096.crt ENV PIA_CA_PATH=/config/cache/ca.rsa.4096.crt
ENV LATENCY_SWITCH_MARGIN_MS=15 ENV LATENCY_SWITCH_MARGIN_MS=15
ENV HEALTH_CHECK_INTERVAL=10
ENV UNHEALTHY_ROTATE_COOLDOWN=60
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+37 -65
View File
@@ -1,12 +1,13 @@
# gluetun-pia-wireguard-rotator # gluetun-pia-wireguard-rotator
Sidecar die op een cron-schema (en bij container-start) de **snelste** PIA WireGuard-server kiest (TCP-latency), lokaal een keypair maakt, via PIA `addKey` registreert, `wg0.conf` op het gedeelde gluetun-volume schrijft, en afhankelijke containers herstart. Sidecar die de **snelste** PIA WireGuard-server kiest (TCP-latency), lokaal een keypair maakt, via PIA `addKey` registreert, `wg0.conf` schrijft, en Gluetun (+ sidecars) herstart. Pollt Gluetun-health elke 10s en herstelt bij `unhealthy`.
## Vereisten ## Vereisten
- Gluetun met `VPN_SERVICE_PROVIDER=custom` en `VPN_TYPE=wireguard` - Gluetun met `VPN_SERVICE_PROVIDER=custom` en `VPN_TYPE=wireguard`
- Gedeeld volume met gluetun (bijv. `/var/dockers/m3u-filter-pia:/gluetun` op gluetun, `/config` op de rotator) - Docker **healthcheck** op de Gluetun-container (anders werkt alleen cron)
- Docker socket (voor `docker restart` van gluetun en eventuele sidecar-containers) - Gedeeld volume met gluetun (bijv. `/var/dockers/m3u-filter-pia:/gluetun``/config`)
- Docker socket (voor `docker restart` / health inspect)
- Actief PIA-abonnement - Actief PIA-abonnement
## Environment variables ## Environment variables
@@ -17,11 +18,9 @@ Sidecar die op een cron-schema (en bij container-start) de **snelste** PIA WireG
|----------|-------------| |----------|-------------|
| `PIA_USER` | PIA-gebruikersnaam | | `PIA_USER` | PIA-gebruikersnaam |
| `PIA_PASS` | PIA-wachtwoord | | `PIA_PASS` | PIA-wachtwoord |
| `PIA_REGIONS` | CSV (`nl_amsterdam,france,belgium`) of JSON-array (`["nl_amsterdam","france"]`) | | `PIA_REGIONS` | CSV (`nl_amsterdam,france,belgium`) of JSON-array |
Region-IDs komen uit de PIA serverlist (niet de OpenVPN-namen uit Gluetun's ingebouwde PIA-provider). Region-IDs komen uit de PIA serverlist.
Lijst opvragen:
```bash ```bash
docker run --rm --entrypoint /opt/venv/bin/python \ docker run --rm --entrypoint /opt/venv/bin/python \
@@ -33,36 +32,30 @@ docker run --rm --entrypoint /opt/venv/bin/python \
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `RESTART_CONTAINERS` | — | CSV of JSON-array met **extra** containers om te herstarten na gluetun (sidecars). Voorbeeld: `SabNZBd,qbittorrent,Spotweb` | | `RESTART_CONTAINERS` | — | Extra containers na gluetun (sidecars) |
| `GLUETUN_CONTAINER` | `m3u-filter-vpn` | Gluetun-container; wordt **altijd als eerste** herstart | | `GLUETUN_CONTAINER` | `m3u-filter-vpn` | Gluetun-container (eerste restart + health poll) |
| `WG_CONFIG_PATH` | `/config/wireguard/wg0.conf` | Pad waar `wg0.conf` wordt geschreven | | `WG_CONFIG_PATH` | `/config/wireguard/wg0.conf` | Pad voor `wg0.conf` |
| `ROTATOR_STATE_PATH` | `/config/rotator-state.json` | Laatste rotatie-metadata | | `ROTATOR_STATE_PATH` | `/config/rotator-state.json` | Rotatie-metadata |
| `ROTATE_CRON` | `0 3 * * *` | 5-veld cron-expressie, in `TZ`. Macros: `@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly` | | `ROTATE_CRON` | `0 3 * * *` | Periodieke latency-check (soft) |
| `REGION_SELECT` | `fastest` | `fastest` = laagste TCP-latency; `random` = willekeurige regio/server | | `HEALTH_CHECK_INTERVAL` | `10` | Seconden tussen health/cron polls |
| `UNHEALTHY_ROTATE_COOLDOWN` | `60` | Wachttijd na unhealthy-rotatie om healthy te worden |
| `REGION_SELECT` | `fastest` | `fastest` of `random` |
| `LATENCY_PORT` | `1337` | TCP-poort voor latency-probes | | `LATENCY_PORT` | `1337` | TCP-poort voor latency-probes |
| `LATENCY_TIMEOUT_SECONDS` | `2` | Timeout per probe | | `LATENCY_TIMEOUT_SECONDS` | `2` | Timeout per probe |
| `LATENCY_SAMPLES` | `2` | Aantal samples per server-IP (gemiddelde) | | `LATENCY_SAMPLES` | `2` | Samples per IP |
| `LATENCY_SWITCH_MARGIN_MS` | `15` | Alleen switchen als de winst ≥ deze marge is | | `LATENCY_SWITCH_MARGIN_MS` | `15` | Soft stickiness bij cron (niet bij unhealthy force) |
| `SERVERLIST_CACHE_PATH` | `/config/cache/pia-serverlist.json` | Disk-cache voor PIA serverlist | | `SERVERLIST_CACHE_PATH` | `/config/cache/pia-serverlist.json` | Serverlist-cache |
| `SERVERLIST_CACHE_TTL` | `24h` | Cache zonder refresh (`Ns`/`Nm`/`Nh`/`Nd` of seconden) | | `SERVERLIST_CACHE_TTL` | `24h` | Cache-TTL |
| `SERVERLIST_CACHE_MAX_AGE` | `168h` | Max leeftijd; stale fallback bij fetch-fout | | `SERVERLIST_CACHE_MAX_AGE` | `168h` | Max stale age |
| `TOKEN_CACHE_PATH` | `/config/cache/pia-token.json` | Disk-cache voor PIA auth-token | | `TOKEN_CACHE_PATH` | `/config/cache/pia-token.json` | Token-cache |
| `TOKEN_CACHE_TTL` | `20h` | Hergebruik token i.p.v. opnieuw inloggen | | `TOKEN_CACHE_TTL` | `20h` | Token hergebruik |
| `PIA_CA_PATH` | `/config/cache/ca.rsa.4096.crt` | Gecachete PIA CA voor `addKey` TLS | | `PIA_CA_PATH` | `/config/cache/ca.rsa.4096.crt` | PIA CA voor addKey |
| `FORCE_TOKEN_REFRESH` | `false` | `true` = token-cache negeren | | `FORCE_TOKEN_REFRESH` | `false` | Token-cache negeren |
| `RATE_LIMIT_WAIT_SECONDS` | `3600` | Cooldown bij PIA rate-limit (blijft gelden na container-restart) | | `RATE_LIMIT_WAIT_SECONDS` | `3600` | Cooldown bij PIA rate-limit |
| `RATE_LIMIT_PATH` | `/config/cache/pia-rate-limit.json` | Persistente cooldown-timestamp | | `RATE_LIMIT_PATH` | `/config/cache/pia-rate-limit.json` | Persistente rate-limit cooldown |
| `TZ` | `Europe/Brussels` | Tijdzone voor scheduling | | `TZ` | `Europe/Brussels` | Tijdzone |
`ROTATE_CRON` voorbeelden: `0 */6 * * *`, `0 3 * * 1-5`, `@hourly`. Quote in Compose: `'ROTATE_CRON=0 3 * * *'`. ## Compose
## Output
- `wireguard/wg0.conf` — Gluetun leest dit als `/gluetun/wireguard/wg0.conf` (overschrijft `WIREGUARD_*` env-vars)
- `rotator-state.json` — gekozen region/server, latency, timestamps
- `cache/` — serverlist, token, PIA CA
## Compose-integratie
```yaml ```yaml
gluetun-pia-wireguard-rotator: gluetun-pia-wireguard-rotator:
@@ -76,8 +69,7 @@ docker run --rm --entrypoint /opt/venv/bin/python \
- PIA_REGIONS=nl_amsterdam,france,belgium - PIA_REGIONS=nl_amsterdam,france,belgium
- GLUETUN_CONTAINER=downloaders-vpn - GLUETUN_CONTAINER=downloaders-vpn
- RESTART_CONTAINERS=SabNZBd,qbittorrent,nzbhydra2,Spotweb - RESTART_CONTAINERS=SabNZBd,qbittorrent,nzbhydra2,Spotweb
- 'ROTATE_CRON=0 3 * * *' - 'ROTATE_CRON=0 */6 * * *'
# optioneel: REGION_SELECT=random
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
- /var/dockers/m3u-filter-pia:/config - /var/dockers/m3u-filter-pia:/config
@@ -85,35 +77,15 @@ docker run --rm --entrypoint /opt/venv/bin/python \
- m3u-filter-vpn - m3u-filter-vpn
``` ```
### Gluetun opschonen (aanbevolen na eerste succesvolle rotatie)
Verwijder uit gluetun zodra `wg0.conf` bestaat:
- `WIREGUARD_ENDPOINT_IP`
- `WIREGUARD_PUBLIC_KEY`
- `WIREGUARD_PRIVATE_KEY`
- `WIREGUARD_ADDRESSES`
Behoud minimaal:
```yaml
environment:
- VPN_SERVICE_PROVIDER=custom
- VPN_TYPE=wireguard
```
## Deploy
1. Push/build image (`Dockers/gluetun-pia-wireguard-rotator/**``bramkel/gluetun-pia-wireguard-rotator:latest`)
2. `docker compose up -d gluetun-pia-wireguard-rotator`
3. Logs: `docker logs gluetun-pia-wireguard-rotator`
## Gedrag ## Gedrag
1. Latency meten over WG-servers in `PIA_REGIONS` (of random) 1. **Startup:** force-rotatie (beste server + nieuwe keypair)
2. **Pin** de snelste server-IP (niet alleen regio) 2. **Elke `HEALTH_CHECK_INTERVAL`:** health van `GLUETUN_CONTAINER` checken
3. Altijd nieuw keypair + `addKey` (reauth), daarna containers herstarten 3. **Unhealthy recovery (max 2 stappen):**
4. Caches: serverlist, token (~20u), CA-cert — alleen om overbodige API-calls te beperken - Stap 1: force beste server + nieuwe keypair (keypair kan invalid zijn)
5. Bij rate-limit: cooldown van `RATE_LIMIT_WAIT_SECONDS` (default 1 uur), **persistent op disk** zodat restarts geen extra API-calls doen; daarna opnieuw proberen. Andere token-endpoints worden eerst nog geprobeerd. - Wacht tot `UNHEALTHY_ROTATE_COOLDOWN` of tot healthy
- Nog unhealthy → stap 2: exclude die IP, force runner-up (#2 latency)
4. **Cron due:** latency opnieuw meten; **zelfde beste server** → geen token/addKey/restarts
5. Rate-limit: persistente cooldown, daarna retry
**Let op:** zet gluetun **niet** in `RESTART_CONTAINERS`; gebruik `GLUETUN_CONTAINER`. Elke echte rotatie geeft korte downtime. **Let op:** zet gluetun niet in `RESTART_CONTAINERS`. Elke echte rotatie geeft korte downtime.
@@ -1,14 +1,16 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Schedule PIA WireGuard rotations via ROTATE_CRON (croniter).""" """Schedule PIA WireGuard rotations via cron + Gluetun health polling."""
from __future__ import annotations from __future__ import annotations
import json
import os import os
import re import re
import subprocess import subprocess
import sys import sys
import time import time
from datetime import datetime from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from croniter import croniter from croniter import croniter
@@ -71,13 +73,49 @@ def next_run(expr: str, after: datetime) -> datetime:
return croniter(expr, after).get_next(datetime) return croniter(expr, after).get_next(datetime)
def sleep_until_next_rotate(expr: str) -> None: def health_check_interval() -> float:
tz = zone() return max(1.0, float(os.environ.get("HEALTH_CHECK_INTERVAL", "10")))
now = datetime.now(tz)
nxt = next_run(expr, now)
wait_s = max(0.0, (nxt - now).total_seconds()) def unhealthy_cooldown_seconds() -> float:
log(f"Next rotation at {nxt.isoformat(timespec='seconds')} (cron '{expr}', TZ={tz.key}) in {int(wait_s)}s") return max(0.0, float(os.environ.get("UNHEALTHY_ROTATE_COOLDOWN", "60")))
time.sleep(wait_s)
def gluetun_container_name() -> str:
return os.environ.get("GLUETUN_CONTAINER", "m3u-filter-vpn").strip() or "m3u-filter-vpn"
def state_path() -> Path:
return Path(os.environ.get("ROTATOR_STATE_PATH", "/config/rotator-state.json"))
def read_state_server_ip() -> str | None:
path = state_path()
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
ip = data.get("server_ip")
return ip if isinstance(ip, str) and ip else None
def gluetun_health_status() -> str | None:
"""Return Docker health status, or None if unavailable / no healthcheck."""
name = gluetun_container_name()
result = subprocess.run(
["docker", "inspect", "-f", "{{if .State.Health}}{{.State.Health.Status}}{{end}}", name],
check=False,
capture_output=True,
text=True,
)
if result.returncode != 0:
err = (result.stderr or result.stdout or "").strip()
log(f"Could not inspect health of {name}: {err or f'exit {result.returncode}'}")
return None
status = (result.stdout or "").strip()
return status or None
def wait_for_rate_limit_cooldown() -> None: def wait_for_rate_limit_cooldown() -> None:
@@ -88,15 +126,15 @@ def wait_for_rate_limit_cooldown() -> None:
time.sleep(remaining) time.sleep(remaining)
def run_rotation(reason: str) -> None: def run_rotation(reason: str, extra_args: list[str] | None = None) -> None:
args = ["/usr/local/bin/rotate.py", *(extra_args or [])]
while True: while True:
wait_for_rate_limit_cooldown() wait_for_rate_limit_cooldown()
log(reason) log(reason)
result = subprocess.run(["/usr/local/bin/rotate.py"], check=False) result = subprocess.run(args, check=False)
if result.returncode == 0: if result.returncode == 0:
return return
if result.returncode == EXIT_RATE_LIMITED: if result.returncode == EXIT_RATE_LIMITED:
# rotate.py / pia.py already marked the cooldown file.
if pia.rate_limit_remaining_seconds() <= 0: if pia.rate_limit_remaining_seconds() <= 0:
pia.mark_rate_limited(reason="rotation exit code 75") pia.mark_rate_limited(reason="rotation exit code 75")
reason = "Retrying rotation after rate-limit wait" reason = "Retrying rotation after rate-limit wait"
@@ -104,16 +142,83 @@ def run_rotation(reason: str) -> None:
raise SystemExit(f"Rotation failed with exit code {result.returncode}") raise SystemExit(f"Rotation failed with exit code {result.returncode}")
def wait_until_healthy_or_timeout(timeout_s: float) -> str | None:
"""Sleep up to timeout_s, returning early if Gluetun becomes non-unhealthy."""
deadline = time.time() + timeout_s
interval = min(health_check_interval(), max(1.0, timeout_s))
while True:
remaining = deadline - time.time()
if remaining <= 0:
break
time.sleep(min(interval, remaining))
status = gluetun_health_status()
if status != "unhealthy":
return status
return gluetun_health_status()
def handle_unhealthy() -> None:
"""Two-step recovery: re-auth best server, then runner-up if still unhealthy."""
log(f"Gluetun container {gluetun_container_name()} is unhealthy; starting recovery")
run_rotation(
"Unhealthy recovery step 1: force rotate to best server (new keypair)",
["--force"],
)
step1_ip = read_state_server_ip()
cooldown = unhealthy_cooldown_seconds()
log(f"Waiting up to {int(cooldown)}s for Gluetun to become healthy after step 1")
status = wait_until_healthy_or_timeout(cooldown)
if status != "unhealthy":
log(f"Gluetun health after step 1: {status or 'unknown/no-healthcheck'}")
return
exclude_args: list[str] = ["--force"]
if step1_ip:
exclude_args.extend(["--exclude-server", step1_ip])
log(f"Still unhealthy; step 2 excluding failed server {step1_ip}")
else:
log("Still unhealthy; step 2 without exclude (no server_ip in state)")
run_rotation(
"Unhealthy recovery step 2: force rotate to runner-up",
exclude_args,
)
log(f"Waiting up to {int(cooldown)}s for Gluetun to become healthy after step 2")
status = wait_until_healthy_or_timeout(cooldown)
log(f"Gluetun health after step 2: {status or 'unknown/no-healthcheck'}")
def main() -> None: def main() -> None:
expr = resolve_cron_expr() expr = resolve_cron_expr()
tz = zone()
interval = health_check_interval()
# Fail fast on bad cron / TZ before rotating. # Fail fast on bad cron / TZ before rotating.
next_run(expr, datetime.now(zone())) nxt = next_run(expr, datetime.now(tz))
log(f"Starting gluetun PIA WireGuard rotator (TZ={zone().key}, ROTATE_CRON='{expr}')") log(
f"Starting gluetun PIA WireGuard rotator "
f"(TZ={tz.key}, ROTATE_CRON='{expr}', HEALTH_CHECK_INTERVAL={interval:g}s)"
)
log(f"Next scheduled rotation at {nxt.isoformat(timespec='seconds')}")
run_rotation("Running rotation on startup", ["--force"])
nxt = next_run(expr, datetime.now(tz))
run_rotation("Running rotation on startup")
while True: while True:
sleep_until_next_rotate(expr) time.sleep(interval)
run_rotation("Running scheduled rotation") now = datetime.now(tz)
status = gluetun_health_status()
if status == "unhealthy":
handle_unhealthy()
nxt = next_run(expr, datetime.now(tz))
log(f"Next scheduled rotation at {nxt.isoformat(timespec='seconds')}")
continue
if now >= nxt:
run_rotation("Running scheduled rotation", ["--skip-same-server"])
nxt = next_run(expr, datetime.now(tz))
log(f"Next scheduled rotation at {nxt.isoformat(timespec='seconds')}")
if __name__ == "__main__": if __name__ == "__main__":
+150 -66
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import argparse
import json import json
import os import os
import random import random
@@ -82,25 +83,14 @@ def average_tcp_latency_ms(ip: str, port: int, timeout: float, samples: int) ->
return sum(readings) / len(readings) return sum(readings) / len(readings)
def pick_fastest( def probe_servers(servers: list[pia.WgServer]) -> list[dict[str, Any]]:
candidates: list[str], """Probe all servers and return per-IP results sorted by latency."""
previous_region: str | None,
previous_server_ip: str | None,
) -> tuple[pia.WgServer, list[dict[str, Any]]]:
port = int(os.environ.get("LATENCY_PORT", "1337")) port = int(os.environ.get("LATENCY_PORT", "1337"))
timeout = float(os.environ.get("LATENCY_TIMEOUT_SECONDS", "2")) timeout = float(os.environ.get("LATENCY_TIMEOUT_SECONDS", "2"))
samples = max(1, int(os.environ.get("LATENCY_SAMPLES", "2"))) samples = max(1, int(os.environ.get("LATENCY_SAMPLES", "2")))
margin = float(os.environ.get("LATENCY_SWITCH_MARGIN_MS", "15"))
serverlist = pia.fetch_serverlist() results: list[dict[str, Any]] = []
servers: list[pia.WgServer] = [] with ThreadPoolExecutor(max_workers=min(32, max(1, len(servers)))) as pool:
for region_id in candidates:
servers.extend(pia.region_wg_servers(serverlist, region_id))
if not servers:
raise SystemExit("No reachable WireGuard servers found for configured regions")
best_by_region: dict[str, dict[str, Any]] = {}
with ThreadPoolExecutor(max_workers=min(32, len(servers))) as pool:
futures = { futures = {
pool.submit(average_tcp_latency_ms, server.ip, port, timeout, samples): server pool.submit(average_tcp_latency_ms, server.ip, port, timeout, samples): server
for server in servers for server in servers
@@ -108,67 +98,73 @@ def pick_fastest(
for future in as_completed(futures): for future in as_completed(futures):
server = futures[future] server = futures[future]
value = future.result() value = future.result()
current = best_by_region.setdefault( results.append(
server.region,
{ {
"region": server.region, "region": server.region,
"latency_ms": None, "server_ip": server.ip,
"server_ip": None, "server_cn": server.cn,
"server_cn": None, "latency_ms": None if value is None else round(value, 2),
"servers": 0, }
"failures": 0,
},
) )
current["servers"] += 1
if value is None:
current["failures"] += 1
continue
if current["latency_ms"] is None or value < current["latency_ms"]:
current["latency_ms"] = round(value, 2)
current["server_ip"] = server.ip
current["server_cn"] = server.cn
results = sorted( results.sort(
best_by_region.values(),
key=lambda item: ( key=lambda item: (
item["latency_ms"] is None, item["latency_ms"] is None,
item["latency_ms"] if item["latency_ms"] is not None else float("inf"), item["latency_ms"] if item["latency_ms"] is not None else float("inf"),
item["region"], item["region"],
), item["server_ip"],
)
) )
for item in results: for item in results:
latency = "timeout" if item["latency_ms"] is None else f"{item['latency_ms']:.2f}ms" latency = "timeout" if item["latency_ms"] is None else f"{item['latency_ms']:.2f}ms"
log( log(f"Latency {item['region']}: {latency} ({item['server_cn']}/{item['server_ip']})")
f"Latency {item['region']}: {latency} " return results
f"(best={item['server_cn']}/{item['server_ip']}, "
f"servers={item['servers']}, failures={item['failures']})"
)
def pick_fastest(
candidates: list[str],
previous_region: str | None,
previous_server_ip: str | None,
*,
exclude_ips: set[str] | None = None,
apply_margin: bool = True,
) -> tuple[pia.WgServer, list[dict[str, Any]]]:
exclude_ips = exclude_ips or set()
margin = float(os.environ.get("LATENCY_SWITCH_MARGIN_MS", "15"))
serverlist = pia.fetch_serverlist()
servers: list[pia.WgServer] = []
for region_id in candidates:
for server in pia.region_wg_servers(serverlist, region_id):
if server.ip in exclude_ips:
continue
servers.append(server)
if not servers:
raise SystemExit("No reachable WireGuard servers left after excludes")
results = probe_servers(servers)
reachable = [item for item in results if item["latency_ms"] is not None] reachable = [item for item in results if item["latency_ms"] is not None]
if not reachable: if not reachable:
raise SystemExit("All latency probes failed; cannot select fastest region") raise SystemExit("All latency probes failed; cannot select fastest server")
winner = reachable[0] winner = reachable[0]
if previous_region: if apply_margin and previous_server_ip and previous_server_ip not in exclude_ips:
previous_result = next((item for item in reachable if item["region"] == previous_region), None) previous_result = next(
(item for item in reachable if item["server_ip"] == previous_server_ip),
None,
)
if previous_result is not None: if previous_result is not None:
improvement = previous_result["latency_ms"] - winner["latency_ms"] improvement = previous_result["latency_ms"] - winner["latency_ms"]
same_server = ( if winner["server_ip"] != previous_server_ip and improvement < margin:
previous_server_ip
and previous_result["server_ip"] == previous_server_ip
)
if winner["region"] != previous_region and improvement < margin:
log( log(
f"Keeping current region {previous_region} " f"Keeping current server {previous_server_ip} "
f"({previous_result['latency_ms']:.2f}ms); " f"({previous_result['latency_ms']:.2f}ms); "
f"best {winner['region']} only {improvement:.2f}ms faster " f"best {winner['server_ip']} only {improvement:.2f}ms faster "
f"(margin {margin:g}ms)" f"(margin {margin:g}ms)"
) )
winner = previous_result winner = previous_result
elif winner["region"] == previous_region and same_server: elif winner["server_ip"] == previous_server_ip:
log(f"Current endpoint {previous_server_ip} is still fastest in {previous_region}") log(f"Current endpoint {previous_server_ip} is still fastest")
elif winner["region"] == previous_region:
log(f"Current region {previous_region} is still fastest")
server = pia.WgServer( server = pia.WgServer(
region=winner["region"], region=winner["region"],
@@ -179,7 +175,13 @@ def pick_fastest(
return server, results return server, results
def pick_random(candidates: list[str], previous_region: str | None) -> pia.WgServer: def pick_random(
candidates: list[str],
previous_region: str | None,
*,
exclude_ips: set[str] | None = None,
) -> pia.WgServer:
exclude_ips = exclude_ips or set()
serverlist = pia.fetch_serverlist() serverlist = pia.fetch_serverlist()
pool = [region for region in candidates if region != previous_region] or list(candidates) pool = [region for region in candidates if region != previous_region] or list(candidates)
if previous_region and previous_region not in pool and len(candidates) == 1: if previous_region and previous_region not in pool and len(candidates) == 1:
@@ -189,15 +191,24 @@ def pick_random(candidates: list[str], previous_region: str | None) -> pia.WgSer
random.shuffle(pool) random.shuffle(pool)
for region_id in pool: for region_id in pool:
servers = pia.region_wg_servers(serverlist, region_id) servers = [
server
for server in pia.region_wg_servers(serverlist, region_id)
if server.ip not in exclude_ips
]
if servers: if servers:
server = random.choice(servers) server = random.choice(servers)
log(f"Randomly selected {server.region} via {server.cn} ({server.ip})") log(f"Randomly selected {server.region} via {server.cn} ({server.ip})")
return server return server
raise SystemExit("No WireGuard servers found for configured regions") raise SystemExit("No WireGuard servers left after excludes")
def pick_server(state_path: Path) -> tuple[pia.WgServer, str, list[dict[str, Any]]]: def pick_server(
state_path: Path,
*,
exclude_ips: set[str] | None = None,
apply_margin: bool = True,
) -> tuple[pia.WgServer, str, list[dict[str, Any]]]:
regions = parse_regions() regions = parse_regions()
state = read_state(state_path) state = read_state(state_path)
previous_region = state.get("region") if isinstance(state.get("region"), str) else None previous_region = state.get("region") if isinstance(state.get("region"), str) else None
@@ -205,11 +216,17 @@ def pick_server(state_path: Path) -> tuple[pia.WgServer, str, list[dict[str, Any
mode = os.environ.get("REGION_SELECT", "fastest").strip().lower() or "fastest" mode = os.environ.get("REGION_SELECT", "fastest").strip().lower() or "fastest"
if mode == "random": if mode == "random":
return pick_random(regions, previous_region), mode, [] return pick_random(regions, previous_region, exclude_ips=exclude_ips), mode, []
if mode != "fastest": if mode != "fastest":
raise SystemExit(f"Invalid REGION_SELECT '{mode}' (expected fastest|random)") raise SystemExit(f"Invalid REGION_SELECT '{mode}' (expected fastest|random)")
server, results = pick_fastest(regions, previous_region, previous_server_ip) server, results = pick_fastest(
regions,
previous_region,
previous_server_ip,
exclude_ips=exclude_ips,
apply_margin=apply_margin,
)
return server, mode, results return server, mode, results
@@ -241,14 +258,17 @@ def write_state(
mode: str, mode: str,
restarted: list[str], restarted: list[str],
latency_results: list[dict[str, Any]], latency_results: list[dict[str, Any]],
*,
skipped: bool = False,
) -> None: ) -> None:
previous = read_state(state_path)
winner = next( winner = next(
( (
item item
for item in latency_results for item in latency_results
if item.get("region") == server.region and item.get("server_ip") == server.ip if item.get("region") == server.region and item.get("server_ip") == server.ip
), ),
next((item for item in latency_results if item.get("region") == server.region), None), next((item for item in latency_results if item.get("server_ip") == server.ip), None),
) )
now = datetime.now().astimezone().isoformat(timespec="seconds") now = datetime.now().astimezone().isoformat(timespec="seconds")
payload: dict[str, Any] = { payload: dict[str, Any] = {
@@ -258,11 +278,17 @@ def write_state(
"selection": mode, "selection": mode,
"wg_config": os.environ.get("WG_CONFIG_PATH", "/config/wireguard/wg0.conf"), "wg_config": os.environ.get("WG_CONFIG_PATH", "/config/wireguard/wg0.conf"),
"restarted_containers": restarted, "restarted_containers": restarted,
"rotated_at": now,
"last_checked_at": now, "last_checked_at": now,
"skipped_regeneration": skipped,
} }
if skipped and isinstance(previous.get("rotated_at"), str):
payload["rotated_at"] = previous["rotated_at"]
else:
payload["rotated_at"] = now
if winner and winner.get("latency_ms") is not None: if winner and winner.get("latency_ms") is not None:
payload["latency_ms"] = winner["latency_ms"] payload["latency_ms"] = winner["latency_ms"]
elif skipped and previous.get("latency_ms") is not None:
payload["latency_ms"] = previous["latency_ms"]
if latency_results: if latency_results:
payload["latency_results"] = latency_results payload["latency_results"] = latency_results
@@ -271,17 +297,70 @@ def write_state(
state_path.chmod(0o644) state_path.chmod(0o644)
def rotate_once() -> None: def config_looks_valid(wg_path: Path) -> bool:
if not wg_path.is_file():
return False
text = wg_path.read_text(encoding="utf-8", errors="replace")
return bool(re.search(r"^\[Interface\]", text, flags=re.MULTILINE))
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Rotate PIA WireGuard config for Gluetun")
parser.add_argument("--list-regions", action="store_true")
parser.add_argument(
"--skip-same-server",
action="store_true",
help="Skip token/addKey/restarts when the selected server is unchanged",
)
parser.add_argument(
"--force",
action="store_true",
help="Always generate a new keypair and restart containers",
)
parser.add_argument(
"--exclude-server",
action="append",
default=[],
help="Exclude this server IP from selection (repeatable)",
)
return parser.parse_args(argv)
def rotate_once(args: argparse.Namespace) -> None:
require_env("PIA_USER") require_env("PIA_USER")
require_env("PIA_PASS") require_env("PIA_PASS")
require_env("PIA_REGIONS") require_env("PIA_REGIONS")
state_path = Path(os.environ.get("ROTATOR_STATE_PATH", "/config/rotator-state.json")) state_path = Path(os.environ.get("ROTATOR_STATE_PATH", "/config/rotator-state.json"))
wg_path = Path(os.environ.get("WG_CONFIG_PATH", "/config/wireguard/wg0.conf")) wg_path = Path(os.environ.get("WG_CONFIG_PATH", "/config/wireguard/wg0.conf"))
state = read_state(state_path)
exclude_ips = {ip for ip in args.exclude_server if ip}
# Unhealthy/force path wants true #1/#2 ranking without stickiness margin.
apply_margin = not args.force and not exclude_ips
server, mode, latency_results = pick_server(state_path) server, mode, latency_results = pick_server(
state_path,
exclude_ips=exclude_ips,
apply_margin=apply_margin,
)
log(f"Selected endpoint: {server.region} / {server.cn} / {server.ip} (mode={mode})") log(f"Selected endpoint: {server.region} / {server.cn} / {server.ip} (mode={mode})")
previous_ip = state.get("server_ip") if isinstance(state.get("server_ip"), str) else None
if args.skip_same_server and not args.force and previous_ip == server.ip and config_looks_valid(wg_path):
log(
f"Skipping PIA token/addKey; endpoint unchanged ({server.region}/{server.ip})"
)
write_state(
state_path,
server,
mode,
[],
latency_results,
skipped=True,
)
log(f"Rotation skipped for {server.region}/{server.ip}")
return
# Write temp file on the same filesystem as the destination so os.replace works # Write temp file on the same filesystem as the destination so os.replace works
# across Docker bind mounts (/tmp is often a different device than /config). # across Docker bind mounts (/tmp is often a different device than /config).
wg_path.parent.mkdir(parents=True, exist_ok=True) wg_path.parent.mkdir(parents=True, exist_ok=True)
@@ -307,7 +386,7 @@ def rotate_once() -> None:
restarted = parse_restart_containers() restarted = parse_restart_containers()
restart_containers(restarted) restart_containers(restarted)
write_state(state_path, server, mode, restarted, latency_results) write_state(state_path, server, mode, restarted, latency_results, skipped=False)
log(f"Rotation complete for {server.region}/{server.ip}") log(f"Rotation complete for {server.region}/{server.ip}")
@@ -321,7 +400,12 @@ def list_regions_main() -> None:
if __name__ == "__main__": if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] in {"--list-regions", "list-regions"}: # Compat: `rotate.py list-regions`
argv = sys.argv[1:]
if argv and argv[0] == "list-regions":
argv = ["--list-regions", *argv[1:]]
args = parse_args(argv)
if args.list_regions:
list_regions_main() list_regions_main()
else: else:
rotate_once() rotate_once(args)