"""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