This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user