diff --git a/Dockers/gluetun-pia-wireguard-rotator/Dockerfile b/Dockers/gluetun-pia-wireguard-rotator/Dockerfile index 9ac488a..f01d0bd 100644 --- a/Dockers/gluetun-pia-wireguard-rotator/Dockerfile +++ b/Dockers/gluetun-pia-wireguard-rotator/Dockerfile @@ -24,5 +24,7 @@ ENV TOKEN_CACHE_PATH=/config/cache/pia-token.json ENV TOKEN_CACHE_TTL=20h ENV PIA_CA_PATH=/config/cache/ca.rsa.4096.crt ENV LATENCY_SWITCH_MARGIN_MS=15 +ENV HEALTH_CHECK_INTERVAL=10 +ENV UNHEALTHY_ROTATE_COOLDOWN=60 ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/Dockers/gluetun-pia-wireguard-rotator/README.md b/Dockers/gluetun-pia-wireguard-rotator/README.md index cf722a0..89e297b 100644 --- a/Dockers/gluetun-pia-wireguard-rotator/README.md +++ b/Dockers/gluetun-pia-wireguard-rotator/README.md @@ -1,12 +1,13 @@ # 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 - 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 socket (voor `docker restart` van gluetun en eventuele sidecar-containers) +- Docker **healthcheck** op de Gluetun-container (anders werkt alleen cron) +- Gedeeld volume met gluetun (bijv. `/var/dockers/m3u-filter-pia:/gluetun` ↔ `/config`) +- Docker socket (voor `docker restart` / health inspect) - Actief PIA-abonnement ## 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_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). - -Lijst opvragen: +Region-IDs komen uit de PIA serverlist. ```bash docker run --rm --entrypoint /opt/venv/bin/python \ @@ -33,36 +32,30 @@ docker run --rm --entrypoint /opt/venv/bin/python \ | Variable | Default | Description | |----------|---------|-------------| -| `RESTART_CONTAINERS` | — | CSV of JSON-array met **extra** containers om te herstarten na gluetun (sidecars). Voorbeeld: `SabNZBd,qbittorrent,Spotweb` | -| `GLUETUN_CONTAINER` | `m3u-filter-vpn` | Gluetun-container; wordt **altijd als eerste** herstart | -| `WG_CONFIG_PATH` | `/config/wireguard/wg0.conf` | Pad waar `wg0.conf` wordt geschreven | -| `ROTATOR_STATE_PATH` | `/config/rotator-state.json` | Laatste rotatie-metadata | -| `ROTATE_CRON` | `0 3 * * *` | 5-veld cron-expressie, in `TZ`. Macros: `@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly` | -| `REGION_SELECT` | `fastest` | `fastest` = laagste TCP-latency; `random` = willekeurige regio/server | +| `RESTART_CONTAINERS` | — | Extra containers na gluetun (sidecars) | +| `GLUETUN_CONTAINER` | `m3u-filter-vpn` | Gluetun-container (eerste restart + health poll) | +| `WG_CONFIG_PATH` | `/config/wireguard/wg0.conf` | Pad voor `wg0.conf` | +| `ROTATOR_STATE_PATH` | `/config/rotator-state.json` | Rotatie-metadata | +| `ROTATE_CRON` | `0 3 * * *` | Periodieke latency-check (soft) | +| `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_TIMEOUT_SECONDS` | `2` | Timeout per probe | -| `LATENCY_SAMPLES` | `2` | Aantal samples per server-IP (gemiddelde) | -| `LATENCY_SWITCH_MARGIN_MS` | `15` | Alleen switchen als de winst ≥ deze marge is | -| `SERVERLIST_CACHE_PATH` | `/config/cache/pia-serverlist.json` | Disk-cache voor PIA serverlist | -| `SERVERLIST_CACHE_TTL` | `24h` | Cache zonder refresh (`Ns`/`Nm`/`Nh`/`Nd` of seconden) | -| `SERVERLIST_CACHE_MAX_AGE` | `168h` | Max leeftijd; stale fallback bij fetch-fout | -| `TOKEN_CACHE_PATH` | `/config/cache/pia-token.json` | Disk-cache voor PIA auth-token | -| `TOKEN_CACHE_TTL` | `20h` | Hergebruik token i.p.v. opnieuw inloggen | -| `PIA_CA_PATH` | `/config/cache/ca.rsa.4096.crt` | Gecachete PIA CA voor `addKey` TLS | -| `FORCE_TOKEN_REFRESH` | `false` | `true` = token-cache negeren | -| `RATE_LIMIT_WAIT_SECONDS` | `3600` | Cooldown bij PIA rate-limit (blijft gelden na container-restart) | -| `RATE_LIMIT_PATH` | `/config/cache/pia-rate-limit.json` | Persistente cooldown-timestamp | -| `TZ` | `Europe/Brussels` | Tijdzone voor scheduling | +| `LATENCY_SAMPLES` | `2` | Samples per IP | +| `LATENCY_SWITCH_MARGIN_MS` | `15` | Soft stickiness bij cron (niet bij unhealthy force) | +| `SERVERLIST_CACHE_PATH` | `/config/cache/pia-serverlist.json` | Serverlist-cache | +| `SERVERLIST_CACHE_TTL` | `24h` | Cache-TTL | +| `SERVERLIST_CACHE_MAX_AGE` | `168h` | Max stale age | +| `TOKEN_CACHE_PATH` | `/config/cache/pia-token.json` | Token-cache | +| `TOKEN_CACHE_TTL` | `20h` | Token hergebruik | +| `PIA_CA_PATH` | `/config/cache/ca.rsa.4096.crt` | PIA CA voor addKey | +| `FORCE_TOKEN_REFRESH` | `false` | Token-cache negeren | +| `RATE_LIMIT_WAIT_SECONDS` | `3600` | Cooldown bij PIA rate-limit | +| `RATE_LIMIT_PATH` | `/config/cache/pia-rate-limit.json` | Persistente rate-limit cooldown | +| `TZ` | `Europe/Brussels` | Tijdzone | -`ROTATE_CRON` voorbeelden: `0 */6 * * *`, `0 3 * * 1-5`, `@hourly`. Quote in Compose: `'ROTATE_CRON=0 3 * * *'`. - -## 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 +## Compose ```yaml gluetun-pia-wireguard-rotator: @@ -76,8 +69,7 @@ docker run --rm --entrypoint /opt/venv/bin/python \ - PIA_REGIONS=nl_amsterdam,france,belgium - GLUETUN_CONTAINER=downloaders-vpn - RESTART_CONTAINERS=SabNZBd,qbittorrent,nzbhydra2,Spotweb - - 'ROTATE_CRON=0 3 * * *' - # optioneel: REGION_SELECT=random + - 'ROTATE_CRON=0 */6 * * *' volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - /var/dockers/m3u-filter-pia:/config @@ -85,35 +77,15 @@ docker run --rm --entrypoint /opt/venv/bin/python \ - 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 -1. Latency meten over WG-servers in `PIA_REGIONS` (of random) -2. **Pin** de snelste server-IP (niet alleen regio) -3. Altijd nieuw keypair + `addKey` (reauth), daarna containers herstarten -4. Caches: serverlist, token (~20u), CA-cert — alleen om overbodige API-calls te beperken -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. +1. **Startup:** force-rotatie (beste server + nieuwe keypair) +2. **Elke `HEALTH_CHECK_INTERVAL`:** health van `GLUETUN_CONTAINER` checken +3. **Unhealthy recovery (max 2 stappen):** + - Stap 1: force beste server + nieuwe keypair (keypair kan invalid zijn) + - 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. diff --git a/Dockers/gluetun-pia-wireguard-rotator/entrypoint.py b/Dockers/gluetun-pia-wireguard-rotator/entrypoint.py index e7a2e98..5a290f4 100644 --- a/Dockers/gluetun-pia-wireguard-rotator/entrypoint.py +++ b/Dockers/gluetun-pia-wireguard-rotator/entrypoint.py @@ -1,14 +1,16 @@ #!/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 +import json import os import re import subprocess import sys import time from datetime import datetime +from pathlib import Path from zoneinfo import ZoneInfo from croniter import croniter @@ -71,13 +73,49 @@ def next_run(expr: str, after: datetime) -> datetime: return croniter(expr, after).get_next(datetime) -def sleep_until_next_rotate(expr: str) -> None: - tz = zone() - now = datetime.now(tz) - nxt = next_run(expr, now) - wait_s = max(0.0, (nxt - now).total_seconds()) - log(f"Next rotation at {nxt.isoformat(timespec='seconds')} (cron '{expr}', TZ={tz.key}) in {int(wait_s)}s") - time.sleep(wait_s) +def health_check_interval() -> float: + return max(1.0, float(os.environ.get("HEALTH_CHECK_INTERVAL", "10"))) + + +def unhealthy_cooldown_seconds() -> float: + return max(0.0, float(os.environ.get("UNHEALTHY_ROTATE_COOLDOWN", "60"))) + + +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: @@ -88,15 +126,15 @@ def wait_for_rate_limit_cooldown() -> None: 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: wait_for_rate_limit_cooldown() log(reason) - result = subprocess.run(["/usr/local/bin/rotate.py"], check=False) + result = subprocess.run(args, check=False) if result.returncode == 0: return if result.returncode == EXIT_RATE_LIMITED: - # rotate.py / pia.py already marked the cooldown file. if pia.rate_limit_remaining_seconds() <= 0: pia.mark_rate_limited(reason="rotation exit code 75") 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}") +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: expr = resolve_cron_expr() + tz = zone() + interval = health_check_interval() # Fail fast on bad cron / TZ before rotating. - next_run(expr, datetime.now(zone())) - log(f"Starting gluetun PIA WireGuard rotator (TZ={zone().key}, ROTATE_CRON='{expr}')") + nxt = next_run(expr, datetime.now(tz)) + 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: - sleep_until_next_rotate(expr) - run_rotation("Running scheduled rotation") + time.sleep(interval) + 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__": diff --git a/Dockers/gluetun-pia-wireguard-rotator/rotate.py b/Dockers/gluetun-pia-wireguard-rotator/rotate.py index e16f7d0..5c17921 100644 --- a/Dockers/gluetun-pia-wireguard-rotator/rotate.py +++ b/Dockers/gluetun-pia-wireguard-rotator/rotate.py @@ -3,6 +3,7 @@ from __future__ import annotations +import argparse import json import os import random @@ -82,25 +83,14 @@ def average_tcp_latency_ms(ip: str, port: int, timeout: float, samples: int) -> return sum(readings) / len(readings) -def pick_fastest( - candidates: list[str], - previous_region: str | None, - previous_server_ip: str | None, -) -> tuple[pia.WgServer, list[dict[str, Any]]]: +def probe_servers(servers: list[pia.WgServer]) -> list[dict[str, Any]]: + """Probe all servers and return per-IP results sorted by latency.""" port = int(os.environ.get("LATENCY_PORT", "1337")) timeout = float(os.environ.get("LATENCY_TIMEOUT_SECONDS", "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() - servers: list[pia.WgServer] = [] - 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: + results: list[dict[str, Any]] = [] + with ThreadPoolExecutor(max_workers=min(32, max(1, len(servers)))) as pool: futures = { pool.submit(average_tcp_latency_ms, server.ip, port, timeout, samples): server for server in servers @@ -108,67 +98,73 @@ def pick_fastest( for future in as_completed(futures): server = futures[future] value = future.result() - current = best_by_region.setdefault( - server.region, + results.append( { "region": server.region, - "latency_ms": None, - "server_ip": None, - "server_cn": None, - "servers": 0, - "failures": 0, - }, + "server_ip": server.ip, + "server_cn": server.cn, + "latency_ms": None if value is None else round(value, 2), + } ) - 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( - best_by_region.values(), + results.sort( key=lambda item: ( item["latency_ms"] is None, item["latency_ms"] if item["latency_ms"] is not None else float("inf"), item["region"], - ), + item["server_ip"], + ) ) for item in results: latency = "timeout" if item["latency_ms"] is None else f"{item['latency_ms']:.2f}ms" - log( - f"Latency {item['region']}: {latency} " - f"(best={item['server_cn']}/{item['server_ip']}, " - f"servers={item['servers']}, failures={item['failures']})" - ) + log(f"Latency {item['region']}: {latency} ({item['server_cn']}/{item['server_ip']})") + return results + +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] 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] - if previous_region: - previous_result = next((item for item in reachable if item["region"] == previous_region), None) + 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["server_ip"] == previous_server_ip), + None, + ) if previous_result is not None: improvement = previous_result["latency_ms"] - winner["latency_ms"] - same_server = ( - previous_server_ip - and previous_result["server_ip"] == previous_server_ip - ) - if winner["region"] != previous_region and improvement < margin: + if winner["server_ip"] != previous_server_ip and improvement < margin: log( - f"Keeping current region {previous_region} " + f"Keeping current server {previous_server_ip} " 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)" ) winner = previous_result - elif winner["region"] == previous_region and same_server: - log(f"Current endpoint {previous_server_ip} is still fastest in {previous_region}") - elif winner["region"] == previous_region: - log(f"Current region {previous_region} is still fastest") + elif winner["server_ip"] == previous_server_ip: + log(f"Current endpoint {previous_server_ip} is still fastest") server = pia.WgServer( region=winner["region"], @@ -179,7 +175,13 @@ def pick_fastest( 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() 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: @@ -189,15 +191,24 @@ def pick_random(candidates: list[str], previous_region: str | None) -> pia.WgSer random.shuffle(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: server = random.choice(servers) log(f"Randomly selected {server.region} via {server.cn} ({server.ip})") 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() state = read_state(state_path) 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" if mode == "random": - return pick_random(regions, previous_region), mode, [] + return pick_random(regions, previous_region, exclude_ips=exclude_ips), mode, [] if mode != "fastest": 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 @@ -241,14 +258,17 @@ def write_state( mode: str, restarted: list[str], latency_results: list[dict[str, Any]], + *, + skipped: bool = False, ) -> None: + previous = read_state(state_path) winner = next( ( item for item in latency_results 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") payload: dict[str, Any] = { @@ -258,11 +278,17 @@ def write_state( "selection": mode, "wg_config": os.environ.get("WG_CONFIG_PATH", "/config/wireguard/wg0.conf"), "restarted_containers": restarted, - "rotated_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: 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: payload["latency_results"] = latency_results @@ -271,17 +297,70 @@ def write_state( 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_PASS") require_env("PIA_REGIONS") 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")) + 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})") + 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 # across Docker bind mounts (/tmp is often a different device than /config). wg_path.parent.mkdir(parents=True, exist_ok=True) @@ -307,7 +386,7 @@ def rotate_once() -> None: restarted = parse_restart_containers() 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}") @@ -321,7 +400,12 @@ def list_regions_main() -> None: 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() else: - rotate_once() + rotate_once(args)