optimize
Build and Push Docker Images / build-and-push (push) Successful in 17s

This commit is contained in:
2026-08-14 20:03:05 +02:00
parent 3eeb93e225
commit bc9d79de02
5 changed files with 552 additions and 288 deletions
@@ -1,21 +1,13 @@
FROM golang:1.25-alpine AS builder
# ccarpinteri fork: uses PIA's central token API instead of regional
# /authv3/generateToken (CN-only certs fail on modern Go / new Server-* hosts).
RUN apk add --no-cache git ca-certificates \
&& go install github.com/ccarpinteri/pia-wg-config@v1.4.0
FROM docker:cli FROM docker:cli
RUN apk add --no-cache bash ca-certificates tzdata python3 py3-pip \ RUN apk add --no-cache ca-certificates tzdata python3 py3-pip \
&& python3 -m venv /opt/venv \ && python3 -m venv /opt/venv \
&& /opt/venv/bin/pip install --no-cache-dir 'croniter==6.2.4' \ && /opt/venv/bin/pip install --no-cache-dir 'croniter==6.2.4' 'cryptography>=42,<47' \
&& apk del py3-pip && apk del py3-pip
ENV PATH="/opt/venv/bin:$PATH" ENV PATH="/opt/venv/bin:$PATH"
COPY --from=builder /go/bin/pia-wg-config /usr/local/bin/pia-wg-config COPY pia.py rotate.py entrypoint.py entrypoint.sh /usr/local/bin/
COPY rotate.py entrypoint.py entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/rotate.py /usr/local/bin/entrypoint.py /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/rotate.py /usr/local/bin/entrypoint.py /usr/local/bin/entrypoint.sh
@@ -28,6 +20,9 @@ ENV REGION_SELECT=fastest
ENV SERVERLIST_CACHE_PATH=/config/cache/pia-serverlist.json ENV SERVERLIST_CACHE_PATH=/config/cache/pia-serverlist.json
ENV SERVERLIST_CACHE_TTL=24h ENV SERVERLIST_CACHE_TTL=24h
ENV SERVERLIST_CACHE_MAX_AGE=168h ENV SERVERLIST_CACHE_MAX_AGE=168h
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 WG_CONFIG_MAX_AGE=7d ENV WG_CONFIG_MAX_AGE=7d
ENV LATENCY_SWITCH_MARGIN_MS=15 ENV LATENCY_SWITCH_MARGIN_MS=15
+31 -39
View File
@@ -1,6 +1,6 @@
# gluetun-pia-wireguard-rotator # gluetun-pia-wireguard-rotator
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. 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.
## Vereisten ## Vereisten
@@ -19,12 +19,14 @@ Sidecar die op een cron-schema (en bij container-start) de **snelste** PIA WireG
| `PIA_PASS` | PIA-wachtwoord | | `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 (`["nl_amsterdam","france"]`) |
Region-codes moeten overeenkomen met `pia-wg-config` (niet de OpenVPN-namen uit Gluetun's ingebouwde PIA-provider). Region-IDs komen uit de PIA serverlist (niet de OpenVPN-namen uit Gluetun's ingebouwde PIA-provider).
Lijst opvragen: Lijst opvragen:
```bash ```bash
docker run --rm --entrypoint pia-wg-config bramkel/gluetun-pia-wireguard-rotator:latest --list-regions docker run --rm --entrypoint /opt/venv/bin/python \
bramkel/gluetun-pia-wireguard-rotator:latest \
/usr/local/bin/rotate.py --list-regions
``` ```
### Optional ### Optional
@@ -35,33 +37,34 @@ docker run --rm --entrypoint pia-wg-config bramkel/gluetun-pia-wireguard-rotator
| `GLUETUN_CONTAINER` | `m3u-filter-vpn` | Gluetun-container; wordt **altijd als eerste** herstart | | `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 | | `WG_CONFIG_PATH` | `/config/wireguard/wg0.conf` | Pad waar `wg0.conf` wordt geschreven |
| `ROTATOR_STATE_PATH` | `/config/rotator-state.json` | Laatste rotatie-metadata | | `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` | | `ROTATE_CRON` | `0 3 * * *` | 5-veld cron-expressie, 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) | | `REGION_SELECT` | `fastest` | `fastest` = laagste TCP-latency; `random` = willekeurige regio/server |
| `LATENCY_PORT` | `1337` | TCP-poort voor latency-probes | | `LATENCY_PORT` | `1337` | TCP-poort voor latency-probes |
| `LATENCY_TIMEOUT_SECONDS` | `2` | Timeout per probe | | `LATENCY_TIMEOUT_SECONDS` | `2` | Timeout per probe |
| `LATENCY_SAMPLES` | `2` | Aantal samples per server-IP (gemiddelde) | | `LATENCY_SAMPLES` | `2` | Aantal samples per server-IP (gemiddelde) |
| `LATENCY_SWITCH_MARGIN_MS` | `15` | Alleen switchen van regio als de winst ≥ deze marge is (minder churn / token-calls) | | `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 (gedeeld met `pia-wg-config`) | | `SERVERLIST_CACHE_PATH` | `/config/cache/pia-serverlist.json` | Disk-cache voor PIA serverlist |
| `SERVERLIST_CACHE_TTL` | `24h` | Gebruik cache zonder refresh (`Ns`/`Nm`/`Nh`/`Nd` of seconden) | | `SERVERLIST_CACHE_TTL` | `24h` | Cache zonder refresh (`Ns`/`Nm`/`Nh`/`Nd` of seconden) |
| `SERVERLIST_CACHE_MAX_AGE` | `168h` | Maximale leeftijd; daarna verplicht vernieuwen (stale fallback bij fetch-fout) | | `SERVERLIST_CACHE_MAX_AGE` | `168h` | Max leeftijd; stale fallback bij fetch-fout |
| `WG_CONFIG_MAX_AGE` | `7d` | Geen nieuwe token/config zolang regio gelijk blijft en `wg0.conf` jonger is | | `TOKEN_CACHE_PATH` | `/config/cache/pia-token.json` | Disk-cache voor PIA auth-token |
| `FORCE_ROTATE` | `false` | `true` = altijd nieuwe config + container-restarts, cache-skip negeren | | `TOKEN_CACHE_TTL` | `20h` | Hergebruik token i.p.v. opnieuw inloggen |
| `RATE_LIMIT_WAIT_SECONDS` | `3600` | Wachttijd bij PIA rate-limit (`429` / `too_many_attempts`) vóór retry | | `PIA_CA_PATH` | `/config/cache/ca.rsa.4096.crt` | Gecachete PIA CA voor `addKey` TLS |
| `WG_CONFIG_MAX_AGE` | `7d` | Geen nieuwe token/`addKey` zolang endpoint gelijk blijft en config jonger is |
| `FORCE_ROTATE` | `false` | `true` = altijd nieuwe config + restarts |
| `FORCE_TOKEN_REFRESH` | `false` | `true` = token-cache negeren |
| `RATE_LIMIT_WAIT_SECONDS` | `3600` | Wachttijd bij PIA rate-limit vóór retry |
| `TZ` | `Europe/Brussels` | Tijdzone voor scheduling | | `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 (`'ROTATE_CRON=0 3 * * *'`) zodat YAML `*` niet speciaal interpreteert. Oude `ROTATE_AT=HH:MM` werkt nog als `ROTATE_CRON` leeg is. `ROTATE_CRON` voorbeelden: `0 */6 * * *`, `0 3 * * 1-5`, `@hourly`. Quote in Compose: `'ROTATE_CRON=0 3 * * *'`.
## Output ## Output
- `wireguard/wg0.conf` op het gedeelde volume — Gluetun leest dit als `/gluetun/wireguard/wg0.conf` en dit **overschrijft** `WIREGUARD_*` environment variables - `wireguard/wg0.conf` — Gluetun leest dit als `/gluetun/wireguard/wg0.conf` (overschrijft `WIREGUARD_*` env-vars)
- `rotator-state.json` laatste gekozen region, latency-resultaten, timestamp en herstartte containers - `rotator-state.json` — gekozen region/server, latency, timestamps
- `cache/` — serverlist, token, PIA CA
## Compose-integratie ## Compose-integratie
Zie [`docker-compose.example.yml`](docker-compose.example.yml) voor een volledig voorbeeld met `m3u-filter-vpn`, m3u-editor en xtream-proxy.
### Nieuwe service toevoegen
```yaml ```yaml
gluetun-pia-wireguard-rotator: gluetun-pia-wireguard-rotator:
image: bramkel/gluetun-pia-wireguard-rotator:latest image: bramkel/gluetun-pia-wireguard-rotator:latest
@@ -83,11 +86,9 @@ Zie [`docker-compose.example.yml`](docker-compose.example.yml) voor een volledig
- m3u-filter-vpn - m3u-filter-vpn
``` ```
Zet `PIA_USER` en `PIA_PASSWORD` in een host-`.env` (niet inline in compose).
### Gluetun opschonen (aanbevolen na eerste succesvolle rotatie) ### Gluetun opschonen (aanbevolen na eerste succesvolle rotatie)
Zodra `wg0.conf` bestaat, heeft het bestand voorrang op env-vars. Verwijder uit `m3u-filter-vpn` om verwarring te voorkomen: Verwijder uit gluetun zodra `wg0.conf` bestaat:
- `WIREGUARD_ENDPOINT_IP` - `WIREGUARD_ENDPOINT_IP`
- `WIREGUARD_PUBLIC_KEY` - `WIREGUARD_PUBLIC_KEY`
@@ -102,27 +103,18 @@ Behoud minimaal:
- VPN_TYPE=wireguard - VPN_TYPE=wireguard
``` ```
Optioneel host-`.env`-keys (`WIREGUARD_*`) opruimen als die niet meer gebruikt worden.
## Deploy ## Deploy
1. Push/build image (`Dockers/gluetun-pia-wireguard-rotator/**` triggert Gitea CI`bramkel/gluetun-pia-wireguard-rotator:latest`) 1. Push/build image (`Dockers/gluetun-pia-wireguard-rotator/**``bramkel/gluetun-pia-wireguard-rotator:latest`)
2. `docker compose up -d gluetun-pia-wireguard-rotator` 2. `docker compose up -d gluetun-pia-wireguard-rotator`
3. Controleer logs: `docker logs gluetun-pia-wireguard-rotator` en `docker logs m3u-filter-vpn` 3. Logs: `docker logs gluetun-pia-wireguard-rotator`
## Gedrag ## Gedrag
1. Bij start / cron: latency meten (of random kiezen) 1. Latency meten over WG-servers in `PIA_REGIONS` (of random)
2. Serverlist komt uit disk-cache (`SERVERLIST_CACHE_*`); token/API alleen bij echte config-refresh 2. **Pin** de snelste server-IP (niet alleen regio)
3. Geen `pia-wg-config` + geen restarts als regio gelijk blijft én `wg0.conf` jonger is dan `WG_CONFIG_MAX_AGE` 3. Keypair lokaal genereren; token + `addKey` alleen bij echte refresh
4. Anders: nieuwe config schrijven, `GLUETUN_CONTAINER` eerst herstarten, daarna `RESTART_CONTAINERS` 4. Caches: serverlist, token (~20u), CA-cert
5. Skip token/`addKey`/restarts als endpoint gelijk blijft én config jonger dan `WG_CONFIG_MAX_AGE`
**Let op:** zet gluetun **niet** in `RESTART_CONTAINERS`; gebruik `GLUETUN_CONTAINER` daarvoor. Sidecars met `network_mode: service:...` horen in `RESTART_CONTAINERS`. **Let op:** zet gluetun **niet** in `RESTART_CONTAINERS`; gebruik `GLUETUN_CONTAINER`. Elke echte rotatie geeft korte downtime.
**Let op:** elke rotatie veroorzaakt kort downtime voor alle VPN-afhankelijke services.
## Security
- De Docker socket geeft de rotator rechten om containers te herstarten; mount read-only waar mogelijk
- `wg0.conf` bevat private keys (`chmod 600`)
- Bewaar PIA-credentials in `.env`, niet in version control
@@ -13,7 +13,7 @@ from zoneinfo import ZoneInfo
from croniter import croniter from croniter import croniter
# Matches rotate.sh EXIT_RATE_LIMITED (EX_TEMPFAIL) # Matches rotate.py / pia.py EXIT_RATE_LIMITED (EX_TEMPFAIL)
EXIT_RATE_LIMITED = 75 EXIT_RATE_LIMITED = 75
CRON_MACROS = { CRON_MACROS = {
@@ -0,0 +1,360 @@
#!/usr/bin/env python3
"""Minimal PIA WireGuard client: serverlist, token, keygen, addKey."""
from __future__ import annotations
import base64
import json
import os
import re
import socket
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime
from http.client import HTTPSConnection
from pathlib import Path
from typing import Any
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
EXIT_RATE_LIMITED = 75
SERVERLIST_URL = "https://serverlist.piaservers.net/vpninfo/servers/v6"
TOKEN_URL = "https://www.privateinternetaccess.com/api/client/v2/token"
PIA_CA_URL = (
"https://raw.githubusercontent.com/pia-foss/manual-connections/master/ca.rsa.4096.crt"
)
_DURATION_UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
def log(msg: str) -> None:
print(f"[{datetime.now().astimezone().isoformat(timespec='seconds')}] {msg}", file=sys.stderr)
def parse_duration_seconds(raw: str, default_seconds: int) -> int:
value = (raw or "").strip().lower()
if not value:
return default_seconds
if value.isdigit():
return int(value)
match = re.fullmatch(r"(\d+)([smhd])", value)
if not match:
raise SystemExit(f"Invalid duration '{raw}' (use seconds or Ns/Nm/Nh/Nd)")
return int(match.group(1)) * _DURATION_UNITS[match.group(2)]
def env_bool(name: str, default: bool = False) -> bool:
raw = os.environ.get(name)
if raw is None or raw.strip() == "":
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def serverlist_cache_path() -> Path:
return Path(os.environ.get("SERVERLIST_CACHE_PATH", "/config/cache/pia-serverlist.json"))
def token_cache_path() -> Path:
return Path(os.environ.get("TOKEN_CACHE_PATH", "/config/cache/pia-token.json"))
def ca_cache_path() -> Path:
return Path(os.environ.get("PIA_CA_PATH", "/config/cache/ca.rsa.4096.crt"))
@dataclass(frozen=True)
class WgServer:
region: str
ip: str
cn: str
@dataclass(frozen=True)
class WgKeys:
private_key: str
public_key: str
def generate_wg_keys() -> WgKeys:
private = X25519PrivateKey.generate()
priv_bytes = private.private_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PrivateFormat.Raw,
encryption_algorithm=serialization.NoEncryption(),
)
pub_bytes = private.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
return WgKeys(
private_key=base64.b64encode(priv_bytes).decode("ascii"),
public_key=base64.b64encode(pub_bytes).decode("ascii"),
)
def parse_serverlist_bytes(raw: bytes) -> dict[str, Any]:
try:
data, _ = json.JSONDecoder().raw_decode(raw.decode("utf-8", errors="replace"))
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 fetch_serverlist() -> dict[str, Any]:
cache_path = serverlist_cache_path()
ttl = parse_duration_seconds(os.environ.get("SERVERLIST_CACHE_TTL", "24h"), 86400)
max_age = parse_duration_seconds(os.environ.get("SERVERLIST_CACHE_MAX_AGE", "168h"), 604800)
force = env_bool("SERVERLIST_FORCE_REFRESH", False)
now = time.time()
cached_raw: bytes | None = None
cache_age: float | None = None
if cache_path.is_file() and not force:
try:
cached_raw = cache_path.read_bytes()
cache_age = now - cache_path.stat().st_mtime
except OSError as exc:
log(f"Server list cache unreadable ({exc}); fetching fresh copy")
if cached_raw is not None and cache_age is not None and cache_age <= ttl:
log(f"Using cached PIA server list (age {int(cache_age)}s <= TTL {ttl}s)")
return parse_serverlist_bytes(cached_raw)
try:
with urllib.request.urlopen(SERVERLIST_URL, timeout=30) as resp:
text = resp.read().decode("utf-8", errors="replace")
data, end = json.JSONDecoder().raw_decode(text)
stripped = text[:end].encode("utf-8")
if not isinstance(data, dict) or "regions" not in data:
raise SystemExit("PIA server list missing regions")
cache_path.parent.mkdir(parents=True, exist_ok=True)
tmp = cache_path.with_suffix(cache_path.suffix + ".tmp")
tmp.write_bytes(stripped)
os.replace(tmp, cache_path)
log(f"Fetched and cached PIA server list -> {cache_path}")
return data
except (urllib.error.URLError, json.JSONDecodeError, OSError) as exc:
if cached_raw is not None and cache_age is not None and cache_age <= max_age:
log(f"Server list fetch failed ({exc}); using stale cache (age {int(cache_age)}s)")
return parse_serverlist_bytes(cached_raw)
raise SystemExit(f"Failed to fetch PIA server list: {exc}") from exc
def list_regions(serverlist: dict[str, Any] | None = None) -> list[dict[str, Any]]:
data = serverlist or fetch_serverlist()
regions = []
for region in data.get("regions", []):
regions.append(
{
"id": region.get("id"),
"name": region.get("name"),
"country": region.get("country"),
"port_forward": bool(region.get("port_forward")),
"offline": bool(region.get("offline")),
"wg_servers": len((region.get("servers") or {}).get("wg") or []),
}
)
regions.sort(key=lambda item: (item.get("country") or "", item.get("id") or ""))
return regions
def region_wg_servers(serverlist: dict[str, Any], region_id: str) -> list[WgServer]:
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") or {}).get("wg") or []
out: list[WgServer] = []
for server in servers:
if not isinstance(server, dict) or not server.get("ip") or not server.get("cn"):
continue
out.append(WgServer(region=region_id, ip=server["ip"], cn=server["cn"]))
return out
log(f"Region {region_id} not found in PIA server list; skipping")
return []
def ensure_pia_ca() -> Path:
path = ca_cache_path()
ttl = parse_duration_seconds(os.environ.get("PIA_CA_CACHE_TTL", "30d"), 30 * 86400)
if path.is_file() and (time.time() - path.stat().st_mtime) <= ttl:
return path
try:
with urllib.request.urlopen(PIA_CA_URL, timeout=30) as resp:
data = resp.read()
if b"BEGIN CERTIFICATE" not in data:
raise SystemExit("Downloaded PIA CA does not look like a certificate")
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_bytes(data)
os.replace(tmp, path)
log(f"Cached PIA CA certificate -> {path}")
except (urllib.error.URLError, OSError) as exc:
if path.is_file():
log(f"PIA CA download failed ({exc}); using existing {path}")
return path
raise SystemExit(f"Failed to download PIA CA certificate: {exc}") from exc
return path
def _rate_limited(body: str, status_code: int) -> bool:
return status_code == 429 or "too_many_attempts" in body
def get_token(username: str, password: str) -> str:
cache_path = token_cache_path()
ttl = parse_duration_seconds(os.environ.get("TOKEN_CACHE_TTL", "20h"), 20 * 3600)
force = env_bool("FORCE_TOKEN_REFRESH", False)
if cache_path.is_file() and not force:
try:
cached = json.loads(cache_path.read_text(encoding="utf-8"))
token = cached.get("token")
obtained = float(cached.get("obtained_at", 0))
if isinstance(token, str) and token and (time.time() - obtained) <= ttl:
log(f"Using cached PIA token (age {int(time.time() - obtained)}s <= TTL {ttl}s)")
return token
except (OSError, json.JSONDecodeError, TypeError, ValueError):
pass
form = urllib.parse.urlencode({"username": username, "password": password}).encode()
req = urllib.request.Request(
TOKEN_URL,
data=form,
method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode("utf-8", errors="replace")
status = resp.status
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
if _rate_limited(body, exc.code):
raise SystemExit(EXIT_RATE_LIMITED) from exc
raise SystemExit(f"PIA token request failed with status {exc.code}: {body}") from exc
except urllib.error.URLError as exc:
raise SystemExit(f"PIA token request failed: {exc}") from exc
if _rate_limited(body, status):
raise SystemExit(EXIT_RATE_LIMITED)
try:
payload = json.loads(body)
except json.JSONDecodeError as exc:
raise SystemExit(f"Invalid PIA token response: {body}") from exc
token = payload.get("token")
if not isinstance(token, str) or not token:
raise SystemExit(f"PIA token response missing token: {body}")
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(
json.dumps({"token": token, "obtained_at": time.time()}, indent=2) + "\n",
encoding="utf-8",
)
cache_path.chmod(0o600)
log("Fetched and cached new PIA token")
return token
class _HTTPSConnectionToIP(HTTPSConnection):
"""HTTPS connection to a fixed IP while presenting server_hostname for SNI/verify."""
def __init__(self, ip: str, port: int, server_hostname: str, context: ssl.SSLContext):
super().__init__(server_hostname, port=port, context=context, timeout=30)
self._connect_ip = ip
self._server_hostname = server_hostname
def connect(self) -> None:
sock = socket.create_connection((self._connect_ip, self.port), self.timeout)
self.sock = self._context.wrap_socket(sock, server_hostname=self._server_hostname)
def add_key(server: WgServer, token: str, public_key: str) -> dict[str, Any]:
ca_path = ensure_pia_ca()
context = ssl.create_default_context(cafile=str(ca_path))
query = urllib.parse.urlencode({"pt": token, "pubkey": public_key})
path = f"/addKey?{query}"
conn = _HTTPSConnectionToIP(server.ip, 1337, server.cn, context)
try:
conn.request("GET", path, headers={"Content-Type": "application/json"})
resp = conn.getresponse()
body = resp.read().decode("utf-8", errors="replace")
status = resp.status
finally:
conn.close()
if _rate_limited(body, status):
raise SystemExit(EXIT_RATE_LIMITED)
if status != 200:
raise SystemExit(f"addKey failed for {server.cn}/{server.ip}: status {status}: {body}")
try:
payload = json.loads(body)
except json.JSONDecodeError as exc:
raise SystemExit(f"Invalid addKey response: {body}") from exc
if payload.get("status") not in (None, "OK"):
raise SystemExit(f"addKey rejected: {body}")
required = ("server_key", "server_ip", "peer_ip", "dns_servers")
missing = [key for key in required if key not in payload]
if missing:
raise SystemExit(f"addKey response missing {missing}: {body}")
if not payload["dns_servers"]:
raise SystemExit(f"addKey response missing dns_servers: {body}")
return payload
def render_wg_config(keys: WgKeys, addkey: dict[str, Any], server: WgServer) -> str:
endpoint_ip = addkey.get("server_ip") or server.ip
endpoint_port = addkey.get("server_port") or 1337
dns = addkey["dns_servers"][0]
return (
"[Interface]\n"
f"PrivateKey = {keys.private_key}\n"
f"Address = {addkey['peer_ip']}\n"
f"DNS = {dns}\n"
"[Peer]\n"
f"PublicKey = {addkey['server_key']}\n"
"AllowedIPs = 0.0.0.0/0\n"
f"Endpoint = {endpoint_ip}:{endpoint_port}\n"
"PersistentKeepalive = 25\n"
)
def generate_wg_config(
username: str,
password: str,
server: WgServer,
outfile: Path,
) -> dict[str, Any]:
log(f"Requesting PIA token (cached when possible)")
token = get_token(username, password)
log("Generating local WireGuard keypair")
keys = generate_wg_keys()
log(f"Registering pubkey via addKey on {server.cn} ({server.ip})")
addkey = add_key(server, token, keys.public_key)
config = render_wg_config(keys, addkey, server)
outfile.write_text(config, encoding="utf-8")
outfile.chmod(0o600)
return {
"region": server.region,
"server_ip": server.ip,
"server_cn": server.cn,
"endpoint_ip": addkey.get("server_ip") or server.ip,
"peer_ip": addkey.get("peer_ip"),
}
+154 -237
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Generate a PIA WireGuard config and restart dependent containers.""" """Pick a PIA region/server and write a WireGuard config for Gluetun."""
from __future__ import annotations from __future__ import annotations
@@ -12,23 +12,12 @@ import subprocess
import sys import sys
import tempfile import tempfile
import time import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
# EX_TEMPFAIL — entrypoint retries after RATE_LIMIT_WAIT_SECONDS import pia
EXIT_RATE_LIMITED = 75
SERVERLIST_URL = "https://serverlist.piaservers.net/vpninfo/servers/v6"
_DURATION_UNITS = {
"s": 1,
"m": 60,
"h": 3600,
"d": 86400,
}
def log(msg: str) -> None: def log(msg: str) -> None:
@@ -42,34 +31,6 @@ def require_env(name: str) -> str:
return value return value
def env_bool(name: str, default: bool = False) -> bool:
raw = os.environ.get(name)
if raw is None or raw.strip() == "":
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def parse_duration_seconds(raw: str, default_seconds: int) -> int:
value = (raw or "").strip().lower()
if not value:
return default_seconds
if value.isdigit():
return int(value)
match = re.fullmatch(r"(\d+)([smhd])", value)
if not match:
raise SystemExit(f"Invalid duration '{raw}' (use seconds or Ns/Nm/Nh/Nd)")
return int(match.group(1)) * _DURATION_UNITS[match.group(2)]
def duration_go(seconds: int) -> str:
"""Format seconds as a Go duration string (pia-wg-config uses time.ParseDuration)."""
if seconds % 3600 == 0:
return f"{seconds // 3600}h"
if seconds % 60 == 0:
return f"{seconds // 60}m"
return f"{seconds}s"
def parse_list(raw: str) -> list[str]: def parse_list(raw: str) -> list[str]:
raw = raw.strip() raw = raw.strip()
if not raw: if not raw:
@@ -99,86 +60,6 @@ def read_state(state_path: Path) -> dict[str, Any]:
return data if isinstance(data, dict) else {} return data if isinstance(data, dict) else {}
def read_previous_region(state_path: Path) -> str | None:
region = read_state(state_path).get("region")
return region if isinstance(region, str) and region else None
def serverlist_cache_path() -> Path:
return Path(os.environ.get("SERVERLIST_CACHE_PATH", "/config/cache/pia-serverlist.json"))
def fetch_serverlist() -> dict[str, Any]:
"""Fetch PIA server list, with on-disk cache shared with pia-wg-config."""
cache_path = serverlist_cache_path()
ttl = parse_duration_seconds(os.environ.get("SERVERLIST_CACHE_TTL", "24h"), 86400)
max_age = parse_duration_seconds(os.environ.get("SERVERLIST_CACHE_MAX_AGE", "168h"), 604800)
force = env_bool("SERVERLIST_FORCE_REFRESH", False)
now = time.time()
cached_raw: bytes | None = None
cache_age: float | None = None
if cache_path.is_file() and not force:
try:
cached_raw = cache_path.read_bytes()
cache_age = now - cache_path.stat().st_mtime
except OSError as exc:
log(f"Server list cache unreadable ({exc}); fetching fresh copy")
cached_raw = None
cache_age = None
if cached_raw is not None and cache_age is not None and cache_age <= ttl:
log(f"Using cached PIA server list (age {int(cache_age)}s <= TTL {ttl}s)")
return parse_serverlist_bytes(cached_raw)
try:
with urllib.request.urlopen(SERVERLIST_URL, timeout=30) as resp:
raw = resp.read()
text = raw.decode("utf-8", errors="replace")
data, end = json.JSONDecoder().raw_decode(text)
stripped = text[:end].encode("utf-8")
if not isinstance(data, dict) or "regions" not in data:
raise SystemExit("PIA server list missing regions")
cache_path.parent.mkdir(parents=True, exist_ok=True)
tmp = cache_path.with_suffix(cache_path.suffix + ".tmp")
tmp.write_bytes(stripped)
os.replace(tmp, cache_path)
log(f"Fetched and cached PIA server list -> {cache_path}")
return data
except (urllib.error.URLError, json.JSONDecodeError, OSError) as exc:
if cached_raw is not None and cache_age is not None and cache_age <= max_age:
log(f"Server list fetch failed ({exc}); using stale cache (age {int(cache_age)}s)")
return parse_serverlist_bytes(cached_raw)
raise SystemExit(f"Failed to fetch PIA server list: {exc}") from exc
def parse_serverlist_bytes(raw: bytes) -> dict[str, Any]:
try:
data, _ = json.JSONDecoder().raw_decode(raw.decode("utf-8", errors="replace"))
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: def tcp_latency_ms(ip: str, port: int, timeout: float) -> float | None:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout) sock.settimeout(timeout)
@@ -202,55 +83,51 @@ def average_tcp_latency_ms(ip: str, port: int, timeout: float, samples: int) ->
return sum(readings) / len(readings) return sum(readings) / len(readings)
def pick_fastest_region( def pick_fastest(
candidates: list[str], candidates: list[str],
previous: str | None, previous_region: str | None,
) -> tuple[str, list[dict[str, Any]]]: previous_server_ip: str | None,
) -> tuple[pia.WgServer, list[dict[str, Any]]]:
port = int(os.environ.get("LATENCY_PORT", "1337")) port = int(os.environ.get("LATENCY_PORT", "1337"))
timeout = float(os.environ.get("LATENCY_TIMEOUT_SECONDS", "2")) timeout = float(os.environ.get("LATENCY_TIMEOUT_SECONDS", "2"))
samples = max(1, int(os.environ.get("LATENCY_SAMPLES", "2"))) samples = max(1, int(os.environ.get("LATENCY_SAMPLES", "2")))
margin = float(os.environ.get("LATENCY_SWITCH_MARGIN_MS", "15")) margin = float(os.environ.get("LATENCY_SWITCH_MARGIN_MS", "15"))
serverlist = fetch_serverlist() serverlist = pia.fetch_serverlist()
probes: list[tuple[str, str]] = [] servers: list[pia.WgServer] = []
server_counts: dict[str, int] = {}
for region_id in candidates: for region_id in candidates:
servers = region_wg_servers(serverlist, region_id) servers.extend(pia.region_wg_servers(serverlist, region_id))
if not servers: 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") raise SystemExit("No reachable WireGuard servers found for configured regions")
best_by_region: dict[str, dict[str, Any]] = { best_by_region: dict[str, dict[str, Any]] = {}
region_id: { with ThreadPoolExecutor(max_workers=min(32, len(servers))) as pool:
"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 = { futures = {
pool.submit(average_tcp_latency_ms, ip, port, timeout, samples): (region_id, ip) pool.submit(average_tcp_latency_ms, server.ip, port, timeout, samples): server
for region_id, ip in probes for server in servers
} }
for future in as_completed(futures): for future in as_completed(futures):
region_id, ip = futures[future] server = futures[future]
value = future.result() value = future.result()
current = best_by_region[region_id] current = best_by_region.setdefault(
server.region,
{
"region": server.region,
"latency_ms": None,
"server_ip": None,
"server_cn": None,
"servers": 0,
"failures": 0,
},
)
current["servers"] += 1
if value is None: if value is None:
current["failures"] += 1 current["failures"] += 1
continue continue
if current["latency_ms"] is None or value < current["latency_ms"]: if current["latency_ms"] is None or value < current["latency_ms"]:
current["latency_ms"] = round(value, 2) current["latency_ms"] = round(value, 2)
current["server_ip"] = ip current["server_ip"] = server.ip
current["server_cn"] = server.cn
results = sorted( results = sorted(
best_by_region.values(), best_by_region.values(),
@@ -260,12 +137,12 @@ def pick_fastest_region(
item["region"], item["region"],
), ),
) )
for item in results: for item in results:
latency = "timeout" if item["latency_ms"] is None else f"{item['latency_ms']:.2f}ms" latency = "timeout" if item["latency_ms"] is None else f"{item['latency_ms']:.2f}ms"
log( log(
f"Latency {item['region']}: {latency} " f"Latency {item['region']}: {latency} "
f"(best_ip={item['server_ip']}, servers={item['servers']}, failures={item['failures']})" f"(best={item['server_cn']}/{item['server_ip']}, "
f"servers={item['servers']}, failures={item['failures']})"
) )
reachable = [item for item in results if item["latency_ms"] is not None] reachable = [item for item in results if item["latency_ms"] is not None]
@@ -273,46 +150,68 @@ def pick_fastest_region(
raise SystemExit("All latency probes failed; cannot select fastest region") raise SystemExit("All latency probes failed; cannot select fastest region")
winner = reachable[0] winner = reachable[0]
if previous: if previous_region:
previous_result = next((item for item in reachable if item["region"] == previous), None) previous_result = next((item for item in reachable if item["region"] == previous_region), None)
if previous_result is not None: if previous_result is not None:
improvement = previous_result["latency_ms"] - winner["latency_ms"] improvement = previous_result["latency_ms"] - winner["latency_ms"]
if winner["region"] != previous and improvement < margin: same_server = (
previous_server_ip
and previous_result["server_ip"] == previous_server_ip
)
if winner["region"] != previous_region and improvement < margin:
log( log(
f"Keeping current region {previous} " f"Keeping current region {previous_region} "
f"({previous_result['latency_ms']:.2f}ms); " f"({previous_result['latency_ms']:.2f}ms); "
f"best {winner['region']} only {improvement:.2f}ms faster " f"best {winner['region']} only {improvement:.2f}ms faster "
f"(margin {margin:g}ms)" f"(margin {margin:g}ms)"
) )
winner = previous_result winner = previous_result
elif winner["region"] == previous: elif winner["region"] == previous_region and same_server:
log(f"Current region {previous} is still fastest") 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")
log(f"Fastest region: {winner['region']} ({winner['latency_ms']:.2f}ms via {winner['server_ip']})") server = pia.WgServer(
return winner["region"], results region=winner["region"],
ip=winner["server_ip"],
cn=winner["server_cn"],
)
log(f"Selected {server.region} via {server.cn} ({server.ip}) at {winner['latency_ms']:.2f}ms")
return server, results
def pick_random_region(candidates: list[str], previous: str | None) -> str: def pick_random(candidates: list[str], previous_region: str | None) -> pia.WgServer:
pool = [region for region in candidates if region != previous] or list(candidates) serverlist = pia.fetch_serverlist()
if previous and previous not in pool and len(candidates) == 1: pool = [region for region in candidates if region != previous_region] or list(candidates)
log(f"Only one region configured; reusing previous: {previous}") if previous_region and previous_region not in pool and len(candidates) == 1:
elif previous and previous not in pool: log(f"Only one region configured; reusing previous: {previous_region}")
log(f"Excluding previous region: {previous}") elif previous_region and previous_region not in pool:
return random.choice(pool) log(f"Excluding previous region: {previous_region}")
random.shuffle(pool)
for region_id in pool:
servers = pia.region_wg_servers(serverlist, region_id)
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")
def pick_region(state_path: Path) -> tuple[str, str, list[dict[str, Any]]]: def pick_server(state_path: Path) -> tuple[pia.WgServer, str, list[dict[str, Any]]]:
regions = parse_regions() regions = parse_regions()
previous = read_previous_region(state_path) state = read_state(state_path)
previous_region = state.get("region") if isinstance(state.get("region"), str) else None
previous_server_ip = state.get("server_ip") if isinstance(state.get("server_ip"), str) else None
mode = os.environ.get("REGION_SELECT", "fastest").strip().lower() or "fastest" mode = os.environ.get("REGION_SELECT", "fastest").strip().lower() or "fastest"
if mode == "random": if mode == "random":
return pick_random_region(regions, previous), mode, [] return pick_random(regions, previous_region), mode, []
if mode != "fastest": if mode != "fastest":
raise SystemExit(f"Invalid REGION_SELECT '{mode}' (expected fastest|random)") raise SystemExit(f"Invalid REGION_SELECT '{mode}' (expected fastest|random)")
region, results = pick_fastest_region(regions, previous) server, results = pick_fastest(regions, previous_region, previous_server_ip)
return region, mode, results return server, mode, results
def parse_restart_containers() -> list[str]: def parse_restart_containers() -> list[str]:
@@ -337,48 +236,6 @@ def restart_containers(containers: list[str]) -> None:
raise SystemExit(f"docker restart failed for container={container}") 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")
cache_path = serverlist_cache_path()
ttl = parse_duration_seconds(os.environ.get("SERVERLIST_CACHE_TTL", "24h"), 86400)
max_age = parse_duration_seconds(os.environ.get("SERVERLIST_CACHE_MAX_AGE", "168h"), 604800)
# Ensure cache exists so pia-wg-config can reuse it.
fetch_serverlist()
cmd = [
"pia-wg-config",
"-v",
"-r",
region,
"-o",
str(outfile),
"--serverlist-cache",
str(cache_path),
"--serverlist-cache-ttl",
duration_go(ttl),
"--serverlist-cache-max-age",
duration_go(max_age),
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 config_age_seconds(wg_path: Path) -> float | None: def config_age_seconds(wg_path: Path) -> float | None:
if not wg_path.is_file(): if not wg_path.is_file():
return None return None
@@ -388,19 +245,26 @@ def config_age_seconds(wg_path: Path) -> float | None:
return None return None
def should_skip_regeneration(region: str, previous: str | None, wg_path: Path) -> bool: def should_skip_regeneration(
if env_bool("FORCE_ROTATE", False): server: pia.WgServer,
state: dict[str, Any],
wg_path: Path,
latency_results: list[dict[str, Any]],
) -> bool:
if pia.env_bool("FORCE_ROTATE", False):
log("FORCE_ROTATE=true; regenerating WireGuard config") log("FORCE_ROTATE=true; regenerating WireGuard config")
return False return False
if previous != region: previous_region = state.get("region")
previous_server_ip = state.get("server_ip")
if previous_region != server.region:
return False return False
age = config_age_seconds(wg_path) age = config_age_seconds(wg_path)
if age is None: if age is None:
return False return False
max_age = parse_duration_seconds(os.environ.get("WG_CONFIG_MAX_AGE", "7d"), 604800) max_age = pia.parse_duration_seconds(os.environ.get("WG_CONFIG_MAX_AGE", "7d"), 604800)
if age > max_age: if age > max_age:
log(f"Existing WireGuard config is stale (age {int(age)}s > max {max_age}s); regenerating") log(f"Existing WireGuard config is stale (age {int(age)}s > max {max_age}s); regenerating")
return False return False
@@ -412,16 +276,36 @@ def should_skip_regeneration(region: str, previous: str | None, wg_path: Path) -
): ):
return False return False
# Same region, different server: only regenerate if clearly faster.
if previous_server_ip and server.ip != previous_server_ip:
margin = float(os.environ.get("LATENCY_SWITCH_MARGIN_MS", "15"))
previous_latency = state.get("latency_ms")
winner = next((item for item in latency_results if item.get("server_ip") == server.ip), None)
new_latency = winner.get("latency_ms") if winner else None
if (
isinstance(previous_latency, (int, float))
and isinstance(new_latency, (int, float))
and (previous_latency - new_latency) < margin
):
log(
f"Keeping current server {previous_server_ip}; "
f"{server.ip} only {previous_latency - new_latency:.2f}ms faster "
f"(margin {margin:g}ms)"
)
return True
log(f"Switching server within {server.region}: {previous_server_ip} -> {server.ip}")
return False
log( log(
f"Skipping PIA token/config request; region unchanged ({region}) " f"Skipping PIA token/addKey; endpoint unchanged "
f"and config age {int(age)}s <= {max_age}s" f"({server.region}/{server.ip}) and config age {int(age)}s <= {max_age}s"
) )
return True return True
def write_state( def write_state(
state_path: Path, state_path: Path,
region: str, server: pia.WgServer,
mode: str, mode: str,
restarted: list[str], restarted: list[str],
latency_results: list[dict[str, Any]], latency_results: list[dict[str, Any]],
@@ -429,10 +313,19 @@ def write_state(
skipped: bool = False, skipped: bool = False,
) -> None: ) -> None:
previous = read_state(state_path) previous = read_state(state_path)
winner = next((item for item in latency_results if item.get("region") == region), None) 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),
)
now = datetime.now().astimezone().isoformat(timespec="seconds") now = datetime.now().astimezone().isoformat(timespec="seconds")
payload: dict[str, Any] = { payload: dict[str, Any] = {
"region": region, "region": server.region,
"server_ip": server.ip,
"server_cn": server.cn,
"selection": mode, "selection": mode,
"wg_config": os.environ.get("WG_CONFIG_PATH", "/config/wireguard/wg0.conf"), "wg_config": os.environ.get("WG_CONFIG_PATH", "/config/wireguard/wg0.conf"),
"restarted_containers": restarted, "restarted_containers": restarted,
@@ -445,7 +338,8 @@ def write_state(
payload["rotated_at"] = now payload["rotated_at"] = now
if winner and winner.get("latency_ms") is not None: if winner and winner.get("latency_ms") is not None:
payload["latency_ms"] = winner["latency_ms"] payload["latency_ms"] = winner["latency_ms"]
payload["server_ip"] = winner.get("server_ip") elif skipped and previous.get("latency_ms") is not None:
payload["latency_ms"] = previous["latency_ms"]
if latency_results: if latency_results:
payload["latency_results"] = latency_results payload["latency_results"] = latency_results
@@ -461,20 +355,31 @@ def rotate_once() -> None:
state_path = Path(os.environ.get("ROTATOR_STATE_PATH", "/config/rotator-state.json")) 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")) wg_path = Path(os.environ.get("WG_CONFIG_PATH", "/config/wireguard/wg0.conf"))
previous = read_previous_region(state_path) state = read_state(state_path)
region, mode, latency_results = pick_region(state_path) server, mode, latency_results = pick_server(state_path)
log(f"Selected region: {region} (mode={mode})") log(f"Selected endpoint: {server.region} / {server.cn} / {server.ip} (mode={mode})")
if should_skip_regeneration(region, previous, wg_path): if should_skip_regeneration(server, state, wg_path, latency_results):
write_state(state_path, region, mode, [], latency_results, skipped=True) # If we decided to keep the previous server IP, persist that identity.
log(f"Rotation skipped for region={region}") keep_ip = state.get("server_ip") if isinstance(state.get("server_ip"), str) else server.ip
keep_cn = state.get("server_cn") if isinstance(state.get("server_cn"), str) else server.cn
if keep_ip != server.ip:
server = pia.WgServer(region=server.region, ip=keep_ip, cn=str(keep_cn or server.cn))
write_state(state_path, server, mode, [], latency_results, skipped=True)
log(f"Rotation skipped for {server.region}/{server.ip}")
return return
with tempfile.TemporaryDirectory(prefix="pia-rotate-") as tmp: with tempfile.TemporaryDirectory(prefix="pia-rotate-") as tmp:
tmp_conf = Path(tmp) / "wg0.conf" tmp_conf = Path(tmp) / "wg0.conf"
log("Generating WireGuard config with pia-wg-config") log("Generating WireGuard config via native PIA client")
generate_wg_config(region, tmp_conf) pia.generate_wg_config(
require_env("PIA_USER"),
require_env("PIA_PASS"),
server,
tmp_conf,
)
text = tmp_conf.read_text(encoding="utf-8", errors="replace") text = tmp_conf.read_text(encoding="utf-8", errors="replace")
if not re.search(r"^\[Interface\]", text, flags=re.MULTILINE): if not re.search(r"^\[Interface\]", text, flags=re.MULTILINE):
raise SystemExit("Generated config missing [Interface] section") raise SystemExit("Generated config missing [Interface] section")
@@ -486,9 +391,21 @@ def rotate_once() -> None:
restarted = parse_restart_containers() restarted = parse_restart_containers()
restart_containers(restarted) restart_containers(restarted)
write_state(state_path, region, mode, restarted, latency_results, skipped=False) write_state(state_path, server, mode, restarted, latency_results, skipped=False)
log(f"Rotation complete for region={region}") log(f"Rotation complete for {server.region}/{server.ip}")
def list_regions_main() -> None:
for region in pia.list_regions():
print(
f"{region['id']:<35} {region['name']:<35} "
f"country={region['country']:<4} port-forward={region['port_forward']!s:<5} "
f"offline={region['offline']} wg={region['wg_servers']}"
)
if __name__ == "__main__": if __name__ == "__main__":
rotate_once() if len(sys.argv) > 1 and sys.argv[1] in {"--list-regions", "list-regions"}:
list_regions_main()
else:
rotate_once()