perfect-postcode/finder/gluetun.py

130 lines
4.6 KiB
Python

"""Gluetun VPN control-server client, shared by every scraper.
The scrapers egress through a Gluetun container's network namespace
(docker-compose.yml, network_mode "container:media_gluetun"), so when a portal
starts refusing our egress IP the cheapest unblocker is to make Gluetun
reconnect to a different VPN server. This module wraps Gluetun's HTTP control
API so both callers share one implementation:
* http_client.py rotates when a portal 403s every request (an egress block).
* zoopla.py rotates when Cloudflare Turnstile fires.
Rotation is SHARED and DISRUPTIVE: every container joined to Gluetun's netns
(here also qbittorrent/sonarr/radarr/prowlarr/seerr/jellyfin) loses
connectivity for the few seconds the tunnel takes to come back. It is therefore
serialised behind a module lock, so N blocked worker threads trigger ONE
rotation rather than N, and callers must budget rotations rather than retry
them freely.
"""
import logging
import threading
import time
import httpx
from constants import GLUETUN_API_KEY, GLUETUN_CONTROL_URL
log = logging.getLogger("rightmove")
# Serialises rotation across worker threads: a rotation tears down the shared
# tunnel, so two concurrent ones would fight (and needlessly double the outage).
_ROTATION_LOCK = threading.Lock()
def _base_url() -> str:
return GLUETUN_CONTROL_URL.rstrip("/")
def _client() -> httpx.Client:
# Talks to the control server directly (not through the VPN proxy).
headers = {}
if GLUETUN_API_KEY:
headers["X-API-Key"] = GLUETUN_API_KEY
return httpx.Client(headers=headers)
def public_ip(client: httpx.Client) -> str | None:
try:
resp = client.get(f"{_base_url()}/v1/publicip/ip", timeout=5.0)
if resp.status_code != 200:
return None
data = resp.json()
except (httpx.HTTPError, ValueError):
return None
return data.get("public_ip") or data.get("ip")
def _set_vpn_status(client: httpx.Client, status: str) -> bool:
"""PUT /v1/vpn/status with {'status': status}. Returns True on 2xx."""
try:
resp = client.put(
f"{_base_url()}/v1/vpn/status",
json={"status": status},
timeout=15.0,
)
except httpx.HTTPError as exc:
log.warning("Gluetun vpn/status %s failed: %s", status, exc)
return False
if resp.status_code == 401:
log.warning(
"Gluetun vpn/status %s: 401 Unauthorized. The API key must be "
"authorised for 'PUT /v1/vpn/status' in Gluetun's auth config.toml",
status,
)
return False
if resp.status_code >= 400:
log.warning(
"Gluetun vpn/status %s returned HTTP %d: %s",
status,
resp.status_code,
resp.text[:200],
)
return False
return True
def rotate_ip(wait_seconds: int = 45) -> bool:
"""Restart Gluetun's VPN and wait for the public IP to change.
Returns True if a new IP was observed within ``wait_seconds``. Serialised:
while one thread rotates, others block here and then see the already-rotated
IP, so a 403 storm across many threads costs one tunnel restart. A failed
rotation always attempts to bring the tunnel back up, because leaving it
stopped would strand every container sharing the netns.
"""
with _ROTATION_LOCK, _client() as client:
old_ip = public_ip(client)
log.info("Requesting Gluetun IP rotation (current IP: %s)", old_ip or "unknown")
stop_attempted = False
restart_confirmed = False
try:
stop_attempted = True
if not _set_vpn_status(client, "stopped"):
return False
time.sleep(2)
restart_confirmed = _set_vpn_status(client, "running")
if not restart_confirmed:
return False
deadline = time.monotonic() + wait_seconds
while time.monotonic() < deadline:
time.sleep(2)
new_ip = public_ip(client)
if new_ip and new_ip != old_ip:
log.info("Gluetun rotated IP: %s -> %s", old_ip or "?", new_ip)
return True
finally:
if stop_attempted and not restart_confirmed:
log.warning(
"Gluetun VPN may be stopped after failed rotation; "
"attempting recovery start"
)
if not _set_vpn_status(client, "running"):
log.error(
"Gluetun VPN recovery start failed; manual intervention required"
)
log.warning("Gluetun IP did not change within %ds", wait_seconds)
return False