mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 12:05:07 +00:00
fix(proxy): check the proxy exit IP before any service request
A dead proxy, or one that exits from this machine's IP, now fails with INVALID_PROXY instead of retrying every request; remote session create also builds the service off the event loop so a stuck check cannot block the server.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from unshackle.core.utils import ip_info
|
||||
|
||||
SERVER = {"ip": "1.1.1.1", "country": "gb"}
|
||||
|
||||
|
||||
def patch_lookups(monkeypatch, proxied, server=SERVER, live_server=None, proxy_errors=0):
|
||||
"""Answer the proxied lookup, the cached server lookup and the live server lookup."""
|
||||
live_server = live_server if live_server is not None else server
|
||||
|
||||
def fake(session=None, cached=False, errors=None):
|
||||
if cached:
|
||||
return server
|
||||
if session is None:
|
||||
return live_server
|
||||
if errors is not None:
|
||||
errors.extend(OSError("refused") for _ in range(proxy_errors))
|
||||
return proxied
|
||||
|
||||
monkeypatch.setattr(ip_info, "get_ip_info", fake)
|
||||
|
||||
|
||||
def test_dead_proxy_raises(monkeypatch):
|
||||
patch_lookups(monkeypatch, None, proxy_errors=len(ip_info.build_providers()))
|
||||
with pytest.raises(ConnectionError, match=r"no IP lookup got through the proxy \(OSError\)"):
|
||||
ip_info.verify_proxy_exit(object())
|
||||
|
||||
|
||||
def test_rate_limited_providers_do_not_reject(monkeypatch):
|
||||
patch_lookups(monkeypatch, None, proxy_errors=len(ip_info.build_providers()) - 1)
|
||||
assert ip_info.verify_proxy_exit(object()) == {}
|
||||
|
||||
|
||||
def test_exit_ip_equal_to_server_raises(monkeypatch):
|
||||
patch_lookups(monkeypatch, {"ip": "1.1.1.1", "country": "gb"})
|
||||
with pytest.raises(ConnectionError, match="proxy exit IP is the IP of this machine"):
|
||||
ip_info.verify_proxy_exit(object())
|
||||
|
||||
|
||||
def test_stale_cached_server_ip_does_not_reject(monkeypatch):
|
||||
patch_lookups(monkeypatch, {"ip": "1.1.1.1", "country": "gb"}, live_server={"ip": "3.3.3.3", "country": "gb"})
|
||||
assert ip_info.verify_proxy_exit(object())["country"] == "gb"
|
||||
|
||||
|
||||
def test_distinct_exit_ip_passes(monkeypatch):
|
||||
patch_lookups(monkeypatch, {"ip": "2.2.2.2", "country": "us"})
|
||||
assert ip_info.verify_proxy_exit(object())["country"] == "us"
|
||||
|
||||
|
||||
def test_unknown_server_ip_skips_comparison(monkeypatch):
|
||||
patch_lookups(monkeypatch, {"ip": "1.1.1.1", "country": "us"}, server=None)
|
||||
assert ip_info.verify_proxy_exit(object())["country"] == "us"
|
||||
|
||||
|
||||
def test_proxied_lookup_ignores_env_proxy(monkeypatch):
|
||||
monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:1")
|
||||
sess = ip_info.lookup_session(SimpleNamespace(proxies={"all": "http://127.0.0.1:2"}))
|
||||
settings = sess.merge_environment_settings("https://ipinfo.io/json", {}, None, None, None)
|
||||
assert settings["proxies"]["https"] == "http://127.0.0.1:2"
|
||||
|
||||
|
||||
def test_local_lookup_keeps_env_proxy(monkeypatch):
|
||||
monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:1")
|
||||
sess = ip_info.lookup_session(None)
|
||||
settings = sess.merge_environment_settings("https://ipinfo.io/json", {}, None, None, None)
|
||||
assert settings["proxies"]["https"] == "http://127.0.0.1:1"
|
||||
|
||||
|
||||
def test_get_ip_info_collects_connection_errors_only(monkeypatch):
|
||||
def refused(session):
|
||||
raise OSError("refused")
|
||||
|
||||
def no_usable_data(session):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(ip_info, "build_providers", lambda: [("a", refused), ("b", no_usable_data)])
|
||||
errors: list[Exception] = []
|
||||
assert ip_info.get_ip_info(SimpleNamespace(proxies={"all": "http://127.0.0.1:9"}), errors=errors) is None
|
||||
assert [str(e) for e in errors] == ["refused"]
|
||||
@@ -179,6 +179,25 @@ def test_setup_list_service_threads_request(monkeypatch):
|
||||
assert seen["authenticated"]
|
||||
|
||||
|
||||
def test_search_reports_failed_proxy_check_as_invalid_proxy(monkeypatch):
|
||||
def failed_check(*args):
|
||||
raise ConnectionError("Proxy check failed: no IP lookup got through the proxy.")
|
||||
|
||||
monkeypatch.setattr(handlers, "load_service_yaml", lambda service: {})
|
||||
monkeypatch.setattr(handlers, "resolve_handler_proxy", lambda *args: ("http://127.0.0.1:9", []))
|
||||
monkeypatch.setattr(handlers, "load_full_cdm", lambda *args: None)
|
||||
monkeypatch.setattr(handlers, "build_parent_ctx", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(handlers.Services, "load", lambda service: None)
|
||||
monkeypatch.setattr(handlers, "instantiate_service", failed_check)
|
||||
monkeypatch.setattr("unshackle.commands.dl.dl.get_cookie_jar", staticmethod(lambda service, profile: None))
|
||||
monkeypatch.setattr("unshackle.commands.dl.dl.get_credentials", staticmethod(lambda service, profile: None))
|
||||
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
handlers.run_service_search({}, "EXAMPLE", "query", request("plainkey"))
|
||||
assert exc_info.value.error_code == APIErrorCode.INVALID_PROXY
|
||||
assert exc_info.value.retryable is False
|
||||
|
||||
|
||||
class TestDownloadGates:
|
||||
"""/api/download submissions and retries obey the same proxy policy as sessions."""
|
||||
|
||||
@@ -288,3 +307,35 @@ class TestDownloadEnforcement:
|
||||
with pytest.raises(Stop):
|
||||
dm.perform_download("job1", "EXAMPLE", "t1", dict(params))
|
||||
assert seen["proxy_providers"] == expected
|
||||
|
||||
|
||||
def test_session_create_builds_service_off_the_event_loop(monkeypatch):
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
|
||||
seen: dict = {}
|
||||
|
||||
class RecordingBuffer(handlers.SessionLogBuffer):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
seen["buffer"] = self
|
||||
|
||||
def failed_check(*args, **kwargs):
|
||||
seen["thread"] = threading.get_ident()
|
||||
logging.getLogger("SlowProxySvc").warning("checking proxy")
|
||||
raise ConnectionError("Proxy check failed: no IP lookup got through the proxy.")
|
||||
|
||||
monkeypatch.setattr(handlers, "validate_service", lambda service, request=None: service)
|
||||
monkeypatch.setattr(handlers, "resolve_handler_proxy", lambda *args: ("http://127.0.0.1:9", []))
|
||||
monkeypatch.setattr(handlers, "server_account_for", lambda *args: False)
|
||||
monkeypatch.setattr(handlers.Services, "load", lambda service: type("SlowProxySvc", (), {}))
|
||||
monkeypatch.setattr(handlers, "SessionLogBuffer", RecordingBuffer)
|
||||
monkeypatch.setattr(handlers, "create_service_instance", failed_check)
|
||||
|
||||
response = asyncio.run(handlers.session_create_handler({"service": "EXAMPLE", "title_id": "t"}))
|
||||
|
||||
assert seen["thread"] != threading.get_ident()
|
||||
assert [r["message"] for r in seen["buffer"].since(0)] == ["checking proxy"]
|
||||
assert json.loads(response.body)["error_code"] == "INVALID_PROXY"
|
||||
|
||||
Reference in New Issue
Block a user