feat: make SOCKS5 proxy configurable per adapter
Add `proxy` config option to server (IRC), teams, telegram, and mumble sections. IRC defaults to false (preserving current direct-connect behavior); all others default to true. The `derp.http` module now accepts `proxy=True/False` on urlopen, create_connection, open_connection, and build_opener -- when false, uses stdlib directly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -280,3 +280,19 @@ class TestBuildServerConfigs:
|
||||
assert len(result) == 1
|
||||
name = list(result.keys())[0]
|
||||
assert result[name] is raw
|
||||
|
||||
|
||||
class TestProxyDefaults:
|
||||
"""Verify proxy defaults in each adapter section."""
|
||||
|
||||
def test_server_proxy_default_false(self):
|
||||
assert DEFAULTS["server"]["proxy"] is False
|
||||
|
||||
def test_teams_proxy_default_true(self):
|
||||
assert DEFAULTS["teams"]["proxy"] is True
|
||||
|
||||
def test_telegram_proxy_default_true(self):
|
||||
assert DEFAULTS["telegram"]["proxy"] is True
|
||||
|
||||
def test_mumble_proxy_default_true(self):
|
||||
assert DEFAULTS["mumble"]["proxy"] is True
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for the SOCKS5 proxy HTTP/TCP module."""
|
||||
"""Tests for the HTTP/TCP module with optional SOCKS5 proxy."""
|
||||
|
||||
import socket
|
||||
import ssl
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -267,3 +268,106 @@ class TestCreateConnection:
|
||||
mock_cls.return_value = sock
|
||||
result = create_connection(("example.com", 443))
|
||||
assert result is sock
|
||||
|
||||
|
||||
# -- proxy=False paths -------------------------------------------------------
|
||||
|
||||
class TestUrlopenDirect:
|
||||
"""Tests for urlopen(proxy=False) -- stdlib direct path."""
|
||||
|
||||
@patch("derp.http.urllib.request.urlopen")
|
||||
def test_uses_stdlib_urlopen(self, mock_urlopen):
|
||||
resp = MagicMock()
|
||||
mock_urlopen.return_value = resp
|
||||
result = urlopen("https://example.com/", proxy=False)
|
||||
mock_urlopen.assert_called_once()
|
||||
assert result is resp
|
||||
|
||||
@patch("derp.http.urllib.request.urlopen")
|
||||
def test_passes_timeout(self, mock_urlopen):
|
||||
resp = MagicMock()
|
||||
mock_urlopen.return_value = resp
|
||||
urlopen("https://example.com/", timeout=15, proxy=False)
|
||||
_, kwargs = mock_urlopen.call_args
|
||||
assert kwargs["timeout"] == 15
|
||||
|
||||
@patch("derp.http.urllib.request.urlopen")
|
||||
def test_passes_context(self, mock_urlopen):
|
||||
resp = MagicMock()
|
||||
mock_urlopen.return_value = resp
|
||||
ctx = ssl.create_default_context()
|
||||
urlopen("https://example.com/", context=ctx, proxy=False)
|
||||
_, kwargs = mock_urlopen.call_args
|
||||
assert kwargs["context"] is ctx
|
||||
|
||||
@patch.object(derp.http, "_get_pool")
|
||||
@patch("derp.http.urllib.request.urlopen")
|
||||
def test_skips_socks_pool(self, mock_urlopen, mock_pool_fn):
|
||||
resp = MagicMock()
|
||||
mock_urlopen.return_value = resp
|
||||
urlopen("https://example.com/", proxy=False)
|
||||
mock_pool_fn.assert_not_called()
|
||||
|
||||
|
||||
class TestBuildOpenerDirect:
|
||||
"""Tests for build_opener(proxy=False) -- no SOCKS5 handler."""
|
||||
|
||||
def test_no_proxy_handler(self):
|
||||
opener = build_opener(proxy=False)
|
||||
proxy_handlers = [h for h in opener.handlers
|
||||
if isinstance(h, _ProxyHandler)]
|
||||
assert len(proxy_handlers) == 0
|
||||
|
||||
def test_with_extra_handler(self):
|
||||
class Custom(urllib.request.HTTPRedirectHandler):
|
||||
pass
|
||||
|
||||
opener = build_opener(Custom, proxy=False)
|
||||
custom = [h for h in opener.handlers if isinstance(h, Custom)]
|
||||
assert len(custom) == 1
|
||||
proxy_handlers = [h for h in opener.handlers
|
||||
if isinstance(h, _ProxyHandler)]
|
||||
assert len(proxy_handlers) == 0
|
||||
|
||||
|
||||
class TestCreateConnectionDirect:
|
||||
"""Tests for create_connection(proxy=False) -- stdlib socket."""
|
||||
|
||||
@patch("derp.http.socket.socket")
|
||||
def test_uses_stdlib_socket(self, mock_sock_cls):
|
||||
sock = MagicMock()
|
||||
mock_sock_cls.return_value = sock
|
||||
result = create_connection(("example.com", 443), proxy=False)
|
||||
mock_sock_cls.assert_called_once_with(
|
||||
socket.AF_INET, socket.SOCK_STREAM,
|
||||
)
|
||||
assert result is sock
|
||||
|
||||
@patch("derp.http.socket.socket")
|
||||
def test_connects_to_target(self, mock_sock_cls):
|
||||
sock = MagicMock()
|
||||
mock_sock_cls.return_value = sock
|
||||
create_connection(("example.com", 443), proxy=False)
|
||||
sock.connect.assert_called_once_with(("example.com", 443))
|
||||
|
||||
@patch("derp.http.socket.socket")
|
||||
def test_sets_timeout(self, mock_sock_cls):
|
||||
sock = MagicMock()
|
||||
mock_sock_cls.return_value = sock
|
||||
create_connection(("example.com", 443), timeout=10, proxy=False)
|
||||
sock.settimeout.assert_called_once_with(10)
|
||||
|
||||
@patch("derp.http.socket.socket")
|
||||
def test_no_socks_proxy_set(self, mock_sock_cls):
|
||||
sock = MagicMock()
|
||||
mock_sock_cls.return_value = sock
|
||||
create_connection(("example.com", 443), proxy=False)
|
||||
sock.set_proxy.assert_not_called()
|
||||
|
||||
@patch("derp.http.socks.socksocket")
|
||||
@patch("derp.http.socket.socket")
|
||||
def test_no_socksocket_created(self, mock_sock_cls, mock_socks_cls):
|
||||
sock = MagicMock()
|
||||
mock_sock_cls.return_value = sock
|
||||
create_connection(("example.com", 443), proxy=False)
|
||||
mock_socks_cls.assert_not_called()
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"""Tests for IRC message parsing and formatting."""
|
||||
|
||||
from derp.irc import _parse_tags, _unescape_tag_value, format_msg, parse
|
||||
from derp.irc import (
|
||||
IRCConnection,
|
||||
_parse_tags,
|
||||
_unescape_tag_value,
|
||||
format_msg,
|
||||
parse,
|
||||
)
|
||||
|
||||
|
||||
class TestParse:
|
||||
@@ -142,3 +148,19 @@ class TestFormat:
|
||||
# No space in tail, not starting with colon, head exists -> no colon
|
||||
result = format_msg("MODE", "#ch", "+o", "nick")
|
||||
assert result == "MODE #ch +o nick"
|
||||
|
||||
|
||||
class TestIRCConnectionProxy:
|
||||
"""IRCConnection proxy flag tests."""
|
||||
|
||||
def test_proxy_default_false(self):
|
||||
conn = IRCConnection("irc.example.com", 6697)
|
||||
assert conn.proxy is False
|
||||
|
||||
def test_proxy_enabled(self):
|
||||
conn = IRCConnection("irc.example.com", 6697, proxy=True)
|
||||
assert conn.proxy is True
|
||||
|
||||
def test_proxy_disabled(self):
|
||||
conn = IRCConnection("irc.example.com", 6697, proxy=False)
|
||||
assert conn.proxy is False
|
||||
|
||||
@@ -889,3 +889,26 @@ class TestMumbleBotConfig:
|
||||
def test_nick_from_username(self):
|
||||
bot = _make_bot()
|
||||
assert bot.nick == "derp"
|
||||
|
||||
def test_proxy_default_true(self):
|
||||
bot = _make_bot()
|
||||
assert bot._proxy is True
|
||||
|
||||
def test_proxy_disabled(self):
|
||||
config = {
|
||||
"mumble": {
|
||||
"enabled": True,
|
||||
"host": "127.0.0.1",
|
||||
"port": 64738,
|
||||
"username": "derp",
|
||||
"password": "",
|
||||
"tls_verify": False,
|
||||
"proxy": False,
|
||||
"admins": [],
|
||||
"operators": [],
|
||||
"trusted": [],
|
||||
},
|
||||
"bot": {"prefix": "!", "rate_limit": 2.0, "rate_burst": 5},
|
||||
}
|
||||
bot = MumbleBot("test", config, PluginRegistry())
|
||||
assert bot._proxy is False
|
||||
|
||||
@@ -732,3 +732,28 @@ class TestTeamsBotPluginManagement:
|
||||
ok, msg = bot.reload_plugin("nonexistent")
|
||||
assert ok is False
|
||||
assert "not loaded" in msg
|
||||
|
||||
|
||||
class TestTeamsBotConfig:
|
||||
def test_proxy_default_true(self):
|
||||
bot = _make_bot()
|
||||
assert bot._proxy is True
|
||||
|
||||
def test_proxy_disabled(self):
|
||||
config = {
|
||||
"teams": {
|
||||
"enabled": True,
|
||||
"bot_name": "derp",
|
||||
"bind": "127.0.0.1",
|
||||
"port": 8081,
|
||||
"webhook_secret": "",
|
||||
"incoming_webhook_url": "",
|
||||
"proxy": False,
|
||||
"admins": [],
|
||||
"operators": [],
|
||||
"trusted": [],
|
||||
},
|
||||
"bot": {"prefix": "!", "rate_limit": 2.0, "rate_burst": 5},
|
||||
}
|
||||
bot = TeamsBot("test", config, PluginRegistry())
|
||||
assert bot._proxy is False
|
||||
|
||||
@@ -764,3 +764,23 @@ class TestTelegramBotConfig:
|
||||
def test_admins_coerced_to_str(self):
|
||||
bot = _make_bot(admins=[111, 222])
|
||||
assert bot._admins == ["111", "222"]
|
||||
|
||||
def test_proxy_default_true(self):
|
||||
bot = _make_bot()
|
||||
assert bot._proxy is True
|
||||
|
||||
def test_proxy_disabled(self):
|
||||
config = {
|
||||
"telegram": {
|
||||
"enabled": True,
|
||||
"bot_token": "t",
|
||||
"poll_timeout": 1,
|
||||
"proxy": False,
|
||||
"admins": [],
|
||||
"operators": [],
|
||||
"trusted": [],
|
||||
},
|
||||
"bot": {"prefix": "!", "rate_limit": 2.0, "rate_burst": 5},
|
||||
}
|
||||
bot = TelegramBot("test", config, PluginRegistry())
|
||||
assert bot._proxy is False
|
||||
|
||||
Reference in New Issue
Block a user