diff --git a/surfsense_backend/app/utils/proxy/__init__.py b/surfsense_backend/app/utils/proxy/__init__.py index 3e4158587..61af110a4 100644 --- a/surfsense_backend/app/utils/proxy/__init__.py +++ b/surfsense_backend/app/utils/proxy/__init__.py @@ -15,6 +15,11 @@ def get_proxy_url() -> str | None: return get_active_provider().get_proxy_url() +def get_sticky_proxy_url(session_id: str) -> str | None: + """Proxy URL pinned to a stable vendor session when supported.""" + return get_active_provider().get_sticky_proxy_url(session_id) + + def get_playwright_proxy() -> dict[str, str] | None: """Playwright-style proxy dict, or ``None`` when not configured.""" return get_active_provider().get_playwright_proxy() @@ -45,5 +50,6 @@ __all__ = [ "get_proxy_url", "get_requests_proxies", "get_residential_proxy_url", + "get_sticky_proxy_url", "is_pool_backed", ] diff --git a/surfsense_backend/app/utils/proxy/base.py b/surfsense_backend/app/utils/proxy/base.py index ed67cd384..a4ef768c7 100644 --- a/surfsense_backend/app/utils/proxy/base.py +++ b/surfsense_backend/app/utils/proxy/base.py @@ -67,6 +67,14 @@ class ProxyProvider(ABC): return None return {"http": proxy_url, "https": proxy_url} + def get_sticky_proxy_url(self, session_id: str) -> str | None: + """Return a proxy URL pinned to ``session_id`` when supported. + + Providers without vendor-specific session routing safely fall back to + their ordinary proxy URL. + """ + return self.get_proxy_url() + def get_location(self) -> str: """Return the proxy's configured exit region (e.g. ``"us"``), or ``""``. diff --git a/surfsense_backend/app/utils/proxy/providers/dataimpulse.py b/surfsense_backend/app/utils/proxy/providers/dataimpulse.py index 17d09325f..ed498321e 100644 --- a/surfsense_backend/app/utils/proxy/providers/dataimpulse.py +++ b/surfsense_backend/app/utils/proxy/providers/dataimpulse.py @@ -16,15 +16,13 @@ Example URL:: http://__cr.us:@gw.dataimpulse.com:823 -ponytail: sticky sessions (a stable exit IP across requests) are another -username suffix (``__sid.``) — the lever the Reddit scraper's README flags as -a TODO for its ``loid``-per-IP flow. Not built yet: Reddit isn't wired to a -route, so there's no caller to thread a session id through. Add a -``get_sticky_proxy_url(session_id)`` here (rewriting the username) when it lands. +Sticky sessions use the vendor's ``__sid.`` username suffix, allowing a +caller to keep cookies and requests on the same exit IP. """ import logging -from urllib.parse import urlsplit +import re +from urllib.parse import quote, urlsplit, urlunsplit from app.config import Config from app.utils.proxy.base import ProxyProvider @@ -34,6 +32,7 @@ logger = logging.getLogger(__name__) # DataImpulse encodes country routing as a "__cr." username suffix; the # country token runs until the next "__" param (e.g. "__sid") or the end. _COUNTRY_MARKER = "__cr." +_SESSION_RE = re.compile(r"__sid\.[A-Za-z0-9_-]+") class DataImpulseProvider(ProxyProvider): @@ -54,3 +53,25 @@ class DataImpulseProvider(ProxyProvider): if _COUNTRY_MARKER not in username: return "" return username.split(_COUNTRY_MARKER, 1)[1].split("__", 1)[0].lower() + + def get_sticky_proxy_url(self, session_id: str) -> str | None: + """Return the configured URL with a deterministic sticky-session suffix.""" + url = self.get_proxy_url() + if not url: + return None + safe_id = re.sub(r"[^A-Za-z0-9_-]", "-", session_id).strip("-") + if not safe_id: + raise ValueError("session_id must contain at least one letter or digit") + parts = urlsplit(url) + username = parts.username or "" + username = _SESSION_RE.sub("", username) + f"__sid.{safe_id}" + userinfo = quote(username, safe="%") + if parts.password is not None: + userinfo += f":{quote(parts.password, safe='%')}" + host = parts.hostname or "" + netloc = f"{userinfo}@{host}" + if parts.port: + netloc += f":{parts.port}" + return urlunsplit( + (parts.scheme, netloc, parts.path, parts.query, parts.fragment) + )