feat: route plugin HTTP traffic through SOCKS5 proxy
Add PySocks dependency and shared src/derp/http.py module providing proxy-aware urlopen() and build_opener() that route through socks5h://127.0.0.1:1080. Subclassed SocksiPyHandler passes SSL context through to HTTPS connections. Swapped 14 external-facing plugins to use the proxied helpers. Local-only traffic (SearXNG, raw DNS/TLS sockets) stays direct. Updated test mocks in test_twitch and test_alert accordingly.
This commit is contained in:
@@ -370,7 +370,7 @@ class TestExtractVideos:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=FakeResp()):
|
||||
with patch.object(_mod, "_urlopen", return_value=FakeResp()):
|
||||
results = _search_youtube("test")
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == "dup1"
|
||||
@@ -388,7 +388,7 @@ class TestSearchYoutube:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=FakeResp()):
|
||||
with patch.object(_mod, "_urlopen", return_value=FakeResp()):
|
||||
results = _search_youtube("test query")
|
||||
assert len(results) == 2
|
||||
assert results[0]["id"] == "abc123"
|
||||
@@ -396,7 +396,7 @@ class TestSearchYoutube:
|
||||
|
||||
def test_http_error_propagates(self):
|
||||
import pytest
|
||||
with patch("urllib.request.urlopen", side_effect=ConnectionError("fail")):
|
||||
with patch.object(_mod, "_urlopen", side_effect=ConnectionError("fail")):
|
||||
with pytest.raises(ConnectionError):
|
||||
_search_youtube("test")
|
||||
|
||||
@@ -413,7 +413,7 @@ class TestSearchTwitch:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=FakeResp()):
|
||||
with patch.object(_mod, "_urlopen", return_value=FakeResp()):
|
||||
results = _search_twitch("minecraft")
|
||||
assert len(results) == 2
|
||||
# Stream
|
||||
@@ -435,7 +435,7 @@ class TestSearchTwitch:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=FakeResp()):
|
||||
with patch.object(_mod, "_urlopen", return_value=FakeResp()):
|
||||
results = _search_twitch("nothing")
|
||||
assert results == []
|
||||
|
||||
@@ -448,13 +448,13 @@ class TestSearchTwitch:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=FakeResp()):
|
||||
with patch.object(_mod, "_urlopen", return_value=FakeResp()):
|
||||
results = _search_twitch("bad")
|
||||
assert results == []
|
||||
|
||||
def test_http_error_propagates(self):
|
||||
import pytest
|
||||
with patch("urllib.request.urlopen", side_effect=ConnectionError("fail")):
|
||||
with patch.object(_mod, "_urlopen", side_effect=ConnectionError("fail")):
|
||||
with pytest.raises(ConnectionError):
|
||||
_search_twitch("test")
|
||||
|
||||
@@ -482,7 +482,7 @@ class TestSearchTwitch:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=FakeResp()):
|
||||
with patch.object(_mod, "_urlopen", return_value=FakeResp()):
|
||||
results = _search_twitch("chat")
|
||||
assert len(results) == 1
|
||||
assert "()" not in results[0]["title"]
|
||||
|
||||
59
tests/test_http.py
Normal file
59
tests/test_http.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Tests for the SOCKS5 proxy HTTP module."""
|
||||
|
||||
import ssl
|
||||
import urllib.request
|
||||
|
||||
from socks import SOCKS5
|
||||
|
||||
from derp.http import _PROXY_ADDR, _PROXY_PORT, _ProxyHandler, build_opener
|
||||
|
||||
|
||||
class TestProxyHandler:
|
||||
def test_uses_socks5(self):
|
||||
handler = _ProxyHandler()
|
||||
assert handler.args[0] == SOCKS5
|
||||
|
||||
def test_proxy_address(self):
|
||||
handler = _ProxyHandler()
|
||||
assert handler.args[1] == _PROXY_ADDR
|
||||
assert handler.args[2] == _PROXY_PORT
|
||||
|
||||
def test_rdns_enabled(self):
|
||||
handler = _ProxyHandler()
|
||||
assert handler.args[3] is True
|
||||
|
||||
def test_default_ssl_context(self):
|
||||
handler = _ProxyHandler()
|
||||
assert isinstance(handler._ssl_context, ssl.SSLContext)
|
||||
|
||||
def test_custom_ssl_context(self):
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
handler = _ProxyHandler(context=ctx)
|
||||
assert handler._ssl_context is ctx
|
||||
|
||||
def test_is_https_handler(self):
|
||||
handler = _ProxyHandler()
|
||||
assert isinstance(handler, urllib.request.HTTPSHandler)
|
||||
|
||||
|
||||
class TestBuildOpener:
|
||||
def test_includes_proxy_handler(self):
|
||||
opener = build_opener()
|
||||
proxy = [h for h in opener.handlers if isinstance(h, _ProxyHandler)]
|
||||
assert len(proxy) == 1
|
||||
|
||||
def test_passes_extra_handlers(self):
|
||||
class Custom(urllib.request.HTTPRedirectHandler):
|
||||
pass
|
||||
|
||||
opener = build_opener(Custom)
|
||||
custom = [h for h in opener.handlers if isinstance(h, Custom)]
|
||||
assert len(custom) == 1
|
||||
|
||||
def test_passes_ssl_context(self):
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
opener = build_opener(context=ctx)
|
||||
proxy = [h for h in opener.handlers if isinstance(h, _ProxyHandler)][0]
|
||||
assert proxy._ssl_context is ctx
|
||||
@@ -345,7 +345,7 @@ class TestQueryStream:
|
||||
"""Test _query_stream response parsing with mocked HTTP."""
|
||||
|
||||
def test_live_response(self):
|
||||
with patch("urllib.request.urlopen", return_value=_FakeGqlResp(GQL_LIVE)):
|
||||
with patch.object(_mod, "_urlopen", return_value=_FakeGqlResp(GQL_LIVE)):
|
||||
result = _mod._query_stream("xqc")
|
||||
assert result["exists"] is True
|
||||
assert result["live"] is True
|
||||
@@ -358,7 +358,7 @@ class TestQueryStream:
|
||||
assert result["error"] == ""
|
||||
|
||||
def test_offline_response(self):
|
||||
with patch("urllib.request.urlopen", return_value=_FakeGqlResp(GQL_OFFLINE)):
|
||||
with patch.object(_mod, "_urlopen", return_value=_FakeGqlResp(GQL_OFFLINE)):
|
||||
result = _mod._query_stream("xqc")
|
||||
assert result["exists"] is True
|
||||
assert result["live"] is False
|
||||
@@ -366,20 +366,20 @@ class TestQueryStream:
|
||||
assert result["stream_id"] == ""
|
||||
|
||||
def test_not_found_response(self):
|
||||
with patch("urllib.request.urlopen", return_value=_FakeGqlResp(GQL_NOT_FOUND)):
|
||||
with patch.object(_mod, "_urlopen", return_value=_FakeGqlResp(GQL_NOT_FOUND)):
|
||||
result = _mod._query_stream("nobody")
|
||||
assert result["exists"] is False
|
||||
assert result["live"] is False
|
||||
|
||||
def test_no_game_response(self):
|
||||
with patch("urllib.request.urlopen", return_value=_FakeGqlResp(GQL_LIVE_NO_GAME)):
|
||||
with patch.object(_mod, "_urlopen", return_value=_FakeGqlResp(GQL_LIVE_NO_GAME)):
|
||||
result = _mod._query_stream("streamer")
|
||||
assert result["exists"] is True
|
||||
assert result["live"] is True
|
||||
assert result["game"] == ""
|
||||
|
||||
def test_network_error(self):
|
||||
with patch("urllib.request.urlopen", side_effect=Exception("timeout")):
|
||||
with patch.object(_mod, "_urlopen", side_effect=Exception("timeout")):
|
||||
result = _mod._query_stream("xqc")
|
||||
assert result["error"] == "timeout"
|
||||
assert result["exists"] is False
|
||||
|
||||
Reference in New Issue
Block a user