From 22d537a41da565d2b87a6badc42fd720a23713e9 Mon Sep 17 00:00:00 2001 From: Bram Date: Fri, 14 Aug 2026 19:48:19 +0200 Subject: [PATCH] best connection --- .../gluetun-pia-wireguard-rotator/Dockerfile | 7 +- .../gluetun-pia-wireguard-rotator/README.md | 17 +- .../entrypoint.py | 2 +- .../gluetun-pia-wireguard-rotator/rotate.py | 325 ++++++++++++++++++ .../gluetun-pia-wireguard-rotator/rotate.sh | 192 ----------- 5 files changed, 341 insertions(+), 202 deletions(-) create mode 100644 Dockers/gluetun-pia-wireguard-rotator/rotate.py delete mode 100644 Dockers/gluetun-pia-wireguard-rotator/rotate.sh diff --git a/Dockers/gluetun-pia-wireguard-rotator/Dockerfile b/Dockers/gluetun-pia-wireguard-rotator/Dockerfile index 74dbdc1..a7433e2 100644 --- a/Dockers/gluetun-pia-wireguard-rotator/Dockerfile +++ b/Dockers/gluetun-pia-wireguard-rotator/Dockerfile @@ -7,7 +7,7 @@ RUN apk add --no-cache git ca-certificates \ FROM docker:cli -RUN apk add --no-cache bash ca-certificates tzdata jq python3 py3-pip \ +RUN apk add --no-cache bash ca-certificates tzdata python3 py3-pip \ && python3 -m venv /opt/venv \ && /opt/venv/bin/pip install --no-cache-dir 'croniter==6.2.4' \ && apk del py3-pip @@ -15,14 +15,15 @@ RUN apk add --no-cache bash ca-certificates tzdata jq python3 py3-pip \ ENV PATH="/opt/venv/bin:$PATH" COPY --from=builder /go/bin/pia-wg-config /usr/local/bin/pia-wg-config -COPY rotate.sh entrypoint.py /usr/local/bin/ +COPY rotate.py entrypoint.py /usr/local/bin/ -RUN chmod +x /usr/local/bin/rotate.sh /usr/local/bin/entrypoint.py +RUN chmod +x /usr/local/bin/rotate.py /usr/local/bin/entrypoint.py ENV TZ=Europe/Brussels ENV WG_CONFIG_PATH=/config/wireguard/wg0.conf ENV ROTATOR_STATE_PATH=/config/rotator-state.json ENV GLUETUN_CONTAINER=m3u-filter-vpn ENV ROTATE_CRON="0 3 * * *" +ENV REGION_SELECT=fastest ENTRYPOINT ["/opt/venv/bin/python", "/usr/local/bin/entrypoint.py"] diff --git a/Dockers/gluetun-pia-wireguard-rotator/README.md b/Dockers/gluetun-pia-wireguard-rotator/README.md index f99dbd1..74ce997 100644 --- a/Dockers/gluetun-pia-wireguard-rotator/README.md +++ b/Dockers/gluetun-pia-wireguard-rotator/README.md @@ -1,6 +1,6 @@ # gluetun-pia-wireguard-rotator -Sidecar die op een cron-schema (en bij container-start) een willekeurige PIA WireGuard-region kiest via [pia-wg-config](https://github.com/ccarpinteri/pia-wg-config), `wg0.conf` op het gedeelde gluetun-volume schrijft, en een configureerbare lijst containers herstart. +Sidecar die op een cron-schema (en bij container-start) de **snelste** PIA WireGuard-region kiest (TCP-latency) via [pia-wg-config](https://github.com/ccarpinteri/pia-wg-config), `wg0.conf` op het gedeelde gluetun-volume schrijft, en een configureerbare lijst containers herstart. ## Vereisten @@ -17,7 +17,7 @@ Sidecar die op een cron-schema (en bij container-start) een willekeurige PIA Wir |----------|-------------| | `PIA_USER` | PIA-gebruikersnaam | | `PIA_PASS` | PIA-wachtwoord | -| `PIA_REGIONS` | CSV (`netherlands,france,belgium`) of JSON-array (`["netherlands","france"]`) | +| `PIA_REGIONS` | CSV (`nl_amsterdam,france,belgium`) of JSON-array (`["nl_amsterdam","france"]`) | Region-codes moeten overeenkomen met `pia-wg-config` (niet de OpenVPN-namen uit Gluetun's ingebouwde PIA-provider). @@ -36,15 +36,19 @@ docker run --rm --entrypoint pia-wg-config bramkel/gluetun-pia-wireguard-rotator | `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 (minuut uur dag-van-maand maand dag-van-week), in `TZ`. Macros: `@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly` | +| `REGION_SELECT` | `fastest` | `fastest` = laagste TCP-latency naar WG-servers in `PIA_REGIONS`; `random` = willekeurig (slaat vorige region over indien mogelijk) | +| `LATENCY_PORT` | `1337` | TCP-poort voor latency-probes | +| `LATENCY_TIMEOUT_SECONDS` | `2` | Timeout per probe | +| `LATENCY_SAMPLES` | `2` | Aantal samples per server-IP (gemiddelde) | | `RATE_LIMIT_WAIT_SECONDS` | `3600` | Wachttijd bij PIA rate-limit (`429` / `too_many_attempts`) vóór retry | | `TZ` | `Europe/Brussels` | Tijdzone voor scheduling | -`ROTATE_CRON` voorbeelden: `0 */6 * * *` (elke 6 uur), `0 3 * * 1-5` (weekdagen 03:00), `@hourly`. Quote de waarde in Compose (`"0 3 * * *"`) zodat YAML `*` niet speciaal interpreteert. Oude `ROTATE_AT=HH:MM` werkt nog als `ROTATE_CRON` leeg is. +`ROTATE_CRON` voorbeelden: `0 */6 * * *` (elke 6 uur), `0 3 * * 1-5` (weekdagen 03:00), `@hourly`. Quote de waarde in Compose (`'ROTATE_CRON=0 3 * * *'`) zodat YAML `*` niet speciaal interpreteert. Oude `ROTATE_AT=HH:MM` werkt nog als `ROTATE_CRON` leeg is. ## Output - `wireguard/wg0.conf` op het gedeelde volume — Gluetun leest dit als `/gluetun/wireguard/wg0.conf` en dit **overschrijft** `WIREGUARD_*` environment variables -- `rotator-state.json` — laatste gekozen region, timestamp en herstartte containers +- `rotator-state.json` — laatste gekozen region, latency-resultaten, timestamp en herstartte containers ## Compose-integratie @@ -61,10 +65,11 @@ Zie [`docker-compose.example.yml`](docker-compose.example.yml) voor een volledig - TZ=Europe/Brussels - PIA_USER=${PIA_USER} - PIA_PASS=${PIA_PASSWORD} - - PIA_REGIONS=netherlands,france,belgium + - PIA_REGIONS=nl_amsterdam,france,belgium - GLUETUN_CONTAINER=downloaders-vpn - RESTART_CONTAINERS=SabNZBd,qbittorrent,nzbhydra2,Spotweb - 'ROTATE_CRON=0 3 * * *' + # optioneel: REGION_SELECT=random volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - /var/dockers/m3u-filter-pia:/config @@ -103,7 +108,7 @@ Optioneel host-`.env`-keys (`WIREGUARD_*`) opruimen als die niet meer gebruikt w 1. Bij start: direct roteren (nieuwe config + container-restarts) 2. Daarna: volgens `ROTATE_CRON` (standaard dagelijks om 03:00) -3. Willekeurige region uit `PIA_REGIONS`, nooit dezelfde als de vorige (uit `rotator-state.json`; uitzondering: maar één region geconfigureerd) +3. Region-keuze: standaard de snelste uit `PIA_REGIONS` (TCP connect naar WG-poort); of `REGION_SELECT=random` 4. `GLUETUN_CONTAINER` wordt altijd als eerste herstart, daarna containers uit `RESTART_CONTAINERS` **Let op:** zet gluetun **niet** in `RESTART_CONTAINERS`; gebruik `GLUETUN_CONTAINER` daarvoor. Sidecars met `network_mode: service:...` horen in `RESTART_CONTAINERS`. diff --git a/Dockers/gluetun-pia-wireguard-rotator/entrypoint.py b/Dockers/gluetun-pia-wireguard-rotator/entrypoint.py index eb2d86d..fcef0bd 100644 --- a/Dockers/gluetun-pia-wireguard-rotator/entrypoint.py +++ b/Dockers/gluetun-pia-wireguard-rotator/entrypoint.py @@ -83,7 +83,7 @@ def run_rotation(reason: str) -> None: wait_s = int(os.environ.get("RATE_LIMIT_WAIT_SECONDS", "3600")) while True: log(reason) - result = subprocess.run(["/usr/local/bin/rotate.sh"], check=False) + result = subprocess.run(["/usr/local/bin/rotate.py"], check=False) if result.returncode == 0: return if result.returncode == EXIT_RATE_LIMITED: diff --git a/Dockers/gluetun-pia-wireguard-rotator/rotate.py b/Dockers/gluetun-pia-wireguard-rotator/rotate.py new file mode 100644 index 0000000..ecf59e3 --- /dev/null +++ b/Dockers/gluetun-pia-wireguard-rotator/rotate.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +"""Generate a PIA WireGuard config and restart dependent containers.""" + +from __future__ import annotations + +import json +import os +import random +import re +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from pathlib import Path +from typing import Any + +# EX_TEMPFAIL — entrypoint retries after RATE_LIMIT_WAIT_SECONDS +EXIT_RATE_LIMITED = 75 +SERVERLIST_URL = "https://serverlist.piaservers.net/vpninfo/servers/v6" + + +def log(msg: str) -> None: + print(f"[{datetime.now().astimezone().isoformat(timespec='seconds')}] {msg}", file=sys.stderr) + + +def require_env(name: str) -> str: + value = os.environ.get(name, "") + if not value: + raise SystemExit(f"Missing required env var: {name}") + return value + + +def parse_list(raw: str) -> list[str]: + raw = raw.strip() + if not raw: + return [] + if raw.startswith("["): + data = json.loads(raw) + if not isinstance(data, list): + raise SystemExit("Expected a JSON array") + return [str(item).strip() for item in data if str(item).strip()] + return [part.strip() for part in raw.split(",") if part.strip()] + + +def parse_regions() -> list[str]: + regions = parse_list(require_env("PIA_REGIONS")) + if not regions: + raise SystemExit("PIA_REGIONS is empty after parsing") + return regions + + +def read_previous_region(state_path: Path) -> str | None: + if not state_path.is_file(): + return None + try: + data = json.loads(state_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + region = data.get("region") + return region if isinstance(region, str) and region else None + + +def fetch_serverlist() -> dict[str, Any]: + try: + with urllib.request.urlopen(SERVERLIST_URL, timeout=30) as resp: + raw = resp.read().decode("utf-8", errors="replace") + except urllib.error.URLError as exc: + raise SystemExit(f"Failed to fetch PIA server list: {exc}") from exc + try: + data, _ = json.JSONDecoder().raw_decode(raw) + except json.JSONDecodeError as exc: + raise SystemExit(f"Failed to parse PIA server list: {exc}") from exc + if not isinstance(data, dict) or "regions" not in data: + raise SystemExit("PIA server list missing regions") + return data + + +def region_wg_servers(serverlist: dict[str, Any], region_id: str) -> list[dict[str, str]]: + for region in serverlist.get("regions", []): + if region.get("id") != region_id: + continue + if region.get("offline"): + log(f"Region {region_id} is marked offline; skipping") + return [] + servers = region.get("servers", {}).get("wg") or [] + return [ + {"ip": s["ip"], "cn": s.get("cn", "")} + for s in servers + if isinstance(s, dict) and s.get("ip") + ] + log(f"Region {region_id} not found in PIA server list; skipping") + return [] + + +def tcp_latency_ms(ip: str, port: int, timeout: float) -> float | None: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + started = time.perf_counter() + try: + sock.connect((ip, port)) + return (time.perf_counter() - started) * 1000.0 + except OSError: + return None + finally: + sock.close() + + +def average_tcp_latency_ms(ip: str, port: int, timeout: float, samples: int) -> float | None: + readings: list[float] = [] + for _ in range(samples): + value = tcp_latency_ms(ip, port, timeout) + if value is None: + return None + readings.append(value) + return sum(readings) / len(readings) + + +def pick_fastest_region(candidates: list[str]) -> tuple[str, list[dict[str, Any]]]: + 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"))) + + serverlist = fetch_serverlist() + probes: list[tuple[str, str]] = [] + server_counts: dict[str, int] = {} + for region_id in candidates: + servers = region_wg_servers(serverlist, region_id) + if not servers: + continue + server_counts[region_id] = len(servers) + for server in servers: + probes.append((region_id, server["ip"])) + + if not probes: + raise SystemExit("No reachable WireGuard servers found for configured regions") + + best_by_region: dict[str, dict[str, Any]] = { + region_id: { + "region": region_id, + "latency_ms": None, + "server_ip": None, + "servers": server_counts[region_id], + "failures": 0, + } + for region_id in server_counts + } + + with ThreadPoolExecutor(max_workers=min(32, len(probes))) as pool: + futures = { + pool.submit(average_tcp_latency_ms, ip, port, timeout, samples): (region_id, ip) + for region_id, ip in probes + } + for future in as_completed(futures): + region_id, ip = futures[future] + value = future.result() + current = best_by_region[region_id] + 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"] = ip + + results = sorted( + best_by_region.values(), + key=lambda item: ( + item["latency_ms"] is None, + item["latency_ms"] if item["latency_ms"] is not None else float("inf"), + item["region"], + ), + ) + + 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_ip={item['server_ip']}, servers={item['servers']}, failures={item['failures']})" + ) + + 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") + + winner = reachable[0] + log(f"Fastest region: {winner['region']} ({winner['latency_ms']:.2f}ms via {winner['server_ip']})") + return winner["region"], results + + +def pick_random_region(candidates: list[str], previous: str | None) -> str: + pool = [region for region in candidates if region != previous] or list(candidates) + if previous and previous not in pool and len(candidates) == 1: + log(f"Only one region configured; reusing previous: {previous}") + elif previous and previous not in pool: + log(f"Excluding previous region: {previous}") + return random.choice(pool) + + +def pick_region(state_path: Path) -> tuple[str, str, list[dict[str, Any]]]: + regions = parse_regions() + previous = read_previous_region(state_path) + mode = os.environ.get("REGION_SELECT", "fastest").strip().lower() or "fastest" + + if mode == "random": + return pick_random_region(regions, previous), mode, [] + if mode != "fastest": + raise SystemExit(f"Invalid REGION_SELECT '{mode}' (expected fastest|random)") + + region, results = pick_fastest_region(regions) + return region, mode, results + + +def parse_restart_containers() -> list[str]: + gluetun = os.environ.get("GLUETUN_CONTAINER", "m3u-filter-vpn").strip() or "m3u-filter-vpn" + ordered = [gluetun] + seen = {gluetun} + for container in parse_list(os.environ.get("RESTART_CONTAINERS", "")): + if container in seen: + continue + ordered.append(container) + seen.add(container) + return ordered + + +def restart_containers(containers: list[str]) -> None: + if not containers: + raise SystemExit("No containers configured to restart") + for container in containers: + log(f"Restarting container: {container}") + result = subprocess.run(["docker", "restart", container], check=False) + if result.returncode != 0: + raise SystemExit(f"docker restart failed for container={container}") + + +def generate_wg_config(region: str, outfile: Path) -> None: + user = require_env("PIA_USER") + password = require_env("PIA_PASS") + cmd = [ + "pia-wg-config", + "-v", + "-r", + region, + "-o", + str(outfile), + user, + password, + ] + result = subprocess.run(cmd, check=False, capture_output=True, text=True) + if result.stdout: + print(result.stdout, end="", file=sys.stderr) + if result.stderr: + print(result.stderr, end="", file=sys.stderr) + + output = f"{result.stdout}{result.stderr}" + if result.returncode == 0: + return + if "too_many_attempts" in output or "status 429" in output: + print(f"pia-wg-config rate-limited for region={region}", file=sys.stderr) + raise SystemExit(EXIT_RATE_LIMITED) + print(f"pia-wg-config failed for region={region}", file=sys.stderr) + raise SystemExit(1) + + +def write_state( + state_path: Path, + region: str, + mode: str, + restarted: list[str], + latency_results: list[dict[str, Any]], +) -> None: + winner = next((item for item in latency_results if item.get("region") == region), None) + payload: dict[str, Any] = { + "region": region, + "selection": mode, + "rotated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + "wg_config": os.environ.get("WG_CONFIG_PATH", "/config/wireguard/wg0.conf"), + "restarted_containers": restarted, + } + if winner and winner.get("latency_ms") is not None: + payload["latency_ms"] = winner["latency_ms"] + payload["server_ip"] = winner.get("server_ip") + if latency_results: + payload["latency_results"] = latency_results + + state_path.parent.mkdir(parents=True, exist_ok=True) + state_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + state_path.chmod(0o644) + + +def rotate_once() -> 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")) + + region, mode, latency_results = pick_region(state_path) + log(f"Selected region: {region} (mode={mode})") + + with tempfile.TemporaryDirectory(prefix="pia-rotate-") as tmp: + tmp_conf = Path(tmp) / "wg0.conf" + log("Generating WireGuard config with pia-wg-config") + generate_wg_config(region, tmp_conf) + text = tmp_conf.read_text(encoding="utf-8", errors="replace") + if not re.search(r"^\[Interface\]", text, flags=re.MULTILINE): + raise SystemExit("Generated config missing [Interface] section") + + wg_path.parent.mkdir(parents=True, exist_ok=True) + os.replace(tmp_conf, wg_path) + wg_path.chmod(0o600) + log(f"Wrote {wg_path}") + + restarted = parse_restart_containers() + restart_containers(restarted) + write_state(state_path, region, mode, restarted, latency_results) + log(f"Rotation complete for region={region}") + + +if __name__ == "__main__": + rotate_once() diff --git a/Dockers/gluetun-pia-wireguard-rotator/rotate.sh b/Dockers/gluetun-pia-wireguard-rotator/rotate.sh deleted file mode 100644 index d5d9280..0000000 --- a/Dockers/gluetun-pia-wireguard-rotator/rotate.sh +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -log() { echo "[$(date -Is)] $*" >&2; } - -require_env() { - local name="$1" - if [[ -z "${!name:-}" ]]; then - >&2 echo "Missing required env var: $name" - exit 2 - fi -} - -parse_regions() { - # CSV: "netherlands,france,belgium" or JSON: '["netherlands","france"]' - local raw="${PIA_REGIONS:-}" - if [[ -z "$raw" ]]; then - >&2 echo "Missing required env var: PIA_REGIONS" - exit 2 - fi - - if [[ "$raw" =~ ^\[.*\]$ ]]; then - jq -r '.[]' <<<"$raw" - return - fi - - tr ',' '\n' <<<"$raw" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' | awk 'NF' -} - -read_previous_region() { - local state_path="${ROTATOR_STATE_PATH:-/config/rotator-state.json}" - if [[ ! -f "$state_path" ]]; then - return 0 - fi - jq -r '.region // empty' "$state_path" 2>/dev/null || true -} - -pick_random_region() { - local regions=() previous candidates=() - while IFS= read -r region; do - [[ -n "$region" ]] && regions+=("$region") - done < <(parse_regions) - - if [[ "${#regions[@]}" -eq 0 ]]; then - >&2 echo "PIA_REGIONS is empty after parsing" - exit 2 - fi - - previous="$(read_previous_region)" - if [[ -n "$previous" ]]; then - for region in "${regions[@]}"; do - [[ "$region" == "$previous" ]] && continue - candidates+=("$region") - done - fi - - if [[ "${#candidates[@]}" -eq 0 ]]; then - if [[ -n "$previous" && "${#regions[@]}" -eq 1 ]]; then - log "Only one region configured; reusing previous: $previous" - fi - candidates=("${regions[@]}") - else - log "Excluding previous region: $previous" - fi - - printf '%s\n' "${candidates[@]}" | shuf -n 1 -} - -parse_additional_restart_containers() { - # Sidecars to restart after gluetun. CSV or JSON array. - local raw="${RESTART_CONTAINERS:-}" - [[ -z "$raw" ]] && return 0 - - if [[ "$raw" =~ ^\[.*\]$ ]]; then - jq -r '.[]' <<<"$raw" - return - fi - - tr ',' '\n' <<<"$raw" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' | awk 'NF' -} - -parse_restart_containers() { - # Gluetun is always restarted first, then any RESTART_CONTAINERS (deduplicated). - local gluetun="${GLUETUN_CONTAINER:-m3u-filter-vpn}" - local -A seen=() - local container - - echo "$gluetun" - seen["$gluetun"]=1 - - while IFS= read -r container; do - [[ -z "$container" ]] && continue - [[ -n "${seen[$container]+x}" ]] && continue - echo "$container" - seen["$container"]=1 - done < <(parse_additional_restart_containers) -} - -restart_containers() { - local container restarted=() - while IFS= read -r container; do - [[ -z "$container" ]] && continue - log "Restarting container: $container" - if ! docker restart "$container"; then - >&2 echo "docker restart failed for container=$container" - exit 1 - fi - restarted+=("$container") - done < <(parse_restart_containers) - - if [[ "${#restarted[@]}" -eq 0 ]]; then - >&2 echo "No containers configured to restart" - exit 1 - fi -} - -write_state() { - local region="$1" - local state_path="${ROTATOR_STATE_PATH:-/config/rotator-state.json}" - local rotated_at restarted_json - rotated_at="$(date -Is)" - restarted_json="$(parse_restart_containers | jq -R -s 'split("\n") | map(select(length > 0))')" - mkdir -p "$(dirname "$state_path")" - jq -n \ - --arg region "$region" \ - --arg rotated_at "$rotated_at" \ - --arg wg_config "${WG_CONFIG_PATH:-/config/wireguard/wg0.conf}" \ - --argjson restarted_containers "$restarted_json" \ - '{region: $region, rotated_at: $rotated_at, wg_config: $wg_config, restarted_containers: $restarted_containers}' >"$state_path" - chmod 644 "$state_path" -} - -# EX_TEMPFAIL — entrypoint retries after RATE_LIMIT_WAIT_SECONDS -readonly EXIT_RATE_LIMITED=75 - -generate_wg_config() { - local region="$1" - local out="$2" - local output rc=0 - - # Capture stdout+stderr so we can detect PIA 429 rate limits. - output="$(pia-wg-config -v -r "$region" -o "$out" "$PIA_USER" "$PIA_PASS" 2>&1)" || rc=$? - printf '%s\n' "$output" >&2 - - if ((rc == 0)); then - return 0 - fi - - if [[ "$output" == *"too_many_attempts"* || "$output" == *"status 429"* ]]; then - >&2 echo "pia-wg-config rate-limited for region=$region" - return "$EXIT_RATE_LIMITED" - fi - - >&2 echo "pia-wg-config failed for region=$region" - return 1 -} - -rotate_once() { - require_env PIA_USER - require_env PIA_PASS - require_env PIA_REGIONS - - local region wg_path tmp_dir - region="$(pick_random_region)" - wg_path="${WG_CONFIG_PATH:-/config/wireguard/wg0.conf}" - - log "Selected region: $region" - - tmp_dir="$(mktemp -d)" - trap 'rm -rf "$tmp_dir"' RETURN - - local tmp_conf="${tmp_dir}/wg0.conf" - log "Generating WireGuard config with pia-wg-config" - generate_wg_config "$region" "$tmp_conf" - - if ! grep -q '^\[Interface\]' "$tmp_conf"; then - >&2 echo "Generated config missing [Interface] section" - exit 1 - fi - - mkdir -p "$(dirname "$wg_path")" - mv "$tmp_conf" "$wg_path" - chmod 600 "$wg_path" - log "Wrote $wg_path" - - restart_containers - write_state "$region" - - log "Rotation complete for region=$region" -} - -rotate_once