130 lines
4.6 KiB
Python
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
|