Enhance gluetun PIA WireGuard rotator with health check and recovery logic. Introduce new environment variables for health check interval and unhealthy cooldown. Refactor entrypoint.py to manage Gluetun health status and implement two-step recovery for unhealthy states. Update rotation logic to account for health status during scheduled rotations.
Build and Push Docker Images / build-and-push (push) Successful in 51s
Build and Push Docker Images / build-and-push (push) Successful in 51s
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user