best connection
Build and Push Docker Images / build-and-push (push) Successful in 35s

This commit is contained in:
2026-08-14 19:48:19 +02:00
parent 7a5b2ac3bd
commit 22d537a41d
5 changed files with 341 additions and 202 deletions
@@ -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