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:
user
2026-02-21 21:19:22 +01:00
parent ca46042c41
commit 9d4cb09069
17 changed files with 355 additions and 47 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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