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

@@ -1,6 +1,19 @@
# derp - Tasks # derp - Tasks
## Current Sprint -- v2.2.0 Mumble Adapter (2026-02-21) ## Current Sprint -- v2.2.0 Configurable Proxy (2026-02-21)
| Pri | Status | Task |
|-----|--------|------|
| P0 | [x] | `src/derp/http.py` -- `proxy` parameter on all public functions |
| P0 | [x] | `src/derp/config.py` -- `proxy` defaults per adapter section |
| P0 | [x] | `src/derp/irc.py` -- optional SOCKS5 for IRC connections |
| P0 | [x] | `src/derp/telegram.py` -- pass proxy config to HTTP calls |
| P0 | [x] | `src/derp/teams.py` -- pass proxy config to HTTP calls |
| P0 | [x] | `src/derp/mumble.py` -- pass proxy config to TCP calls |
| P1 | [x] | Tests: proxy toggle paths (24 new cases, 1494 total) |
| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md, API.md) |
## Previous Sprint -- v2.2.0 Mumble Adapter (2026-02-21)
| Pri | Status | Task | | Pri | Status | Task |
|-----|--------|------| |-----|--------|------|

View File

@@ -189,14 +189,15 @@ SQLite-backed key-value store. Each plugin gets its own namespace.
## `derp.http` -- HTTP & Network ## `derp.http` -- HTTP & Network
All outbound traffic routes through the configured SOCKS5 proxy. HTTP/TCP helpers with optional SOCKS5 proxy routing. All functions accept
a `proxy` parameter (default `True`) to toggle SOCKS5.
| Function | Signature | Description | | Function | Signature | Description |
|----------|-----------|-------------| |----------|-----------|-------------|
| `urlopen` | `(req, *, timeout=None, context=None, retries=None)` | Proxy-aware HTTP request with connection pooling and retries | | `urlopen` | `(req, *, timeout=None, context=None, retries=None, proxy=True)` | HTTP request with optional SOCKS5, connection pooling, retries |
| `build_opener` | `(*handlers, context=None)` | Proxy-aware `urllib.request.build_opener` replacement | | `build_opener` | `(*handlers, context=None, proxy=True)` | Build URL opener, optionally with SOCKS5 handler |
| `create_connection` | `(address, *, timeout=None)` | SOCKS5-proxied `socket.create_connection` with retries | | `create_connection` | `(address, *, timeout=None, proxy=True)` | TCP `socket.create_connection` with optional SOCKS5, retries |
| `open_connection` | `(host, port, *, timeout=None)` | SOCKS5-proxied `asyncio.open_connection` with retries | | `open_connection` | `(host, port, *, timeout=None, proxy=True)` | Async `asyncio.open_connection` with optional SOCKS5, retries |
--- ---

View File

@@ -488,6 +488,7 @@ Auth: HMAC-SHA256 via `X-Signature` header. Starts on IRC connect.
# config/derp.toml # config/derp.toml
[teams] [teams]
enabled = true enabled = true
proxy = true # SOCKS5 proxy for outbound HTTP
bot_name = "derp" bot_name = "derp"
bind = "127.0.0.1" bind = "127.0.0.1"
port = 8081 port = 8081
@@ -510,6 +511,7 @@ Replies returned as JSON in HTTP response. IRC-only commands (kick, ban, topic)
# config/derp.toml # config/derp.toml
[telegram] [telegram]
enabled = true enabled = true
proxy = true # SOCKS5 proxy for HTTP
bot_token = "123456:ABC-DEF..." # from @BotFather bot_token = "123456:ABC-DEF..." # from @BotFather
poll_timeout = 30 # long-poll seconds poll_timeout = 30 # long-poll seconds
admins = [123456789] # Telegram user IDs admins = [123456789] # Telegram user IDs
@@ -517,8 +519,8 @@ operators = []
trusted = [] trusted = []
``` ```
Long-polling via `getUpdates` -- no public endpoint needed. All HTTP Long-polling via `getUpdates` -- no public endpoint needed. HTTP through
through SOCKS5 proxy. Strips `@botusername` suffix in groups. Messages SOCKS5 proxy by default (`proxy = true`). Strips `@botusername` suffix in groups. Messages
split at 4096 chars. IRC-only commands are no-ops. ~90% of plugins work. split at 4096 chars. IRC-only commands are no-ops. ~90% of plugins work.
## Mumble Integration ## Mumble Integration
@@ -527,6 +529,7 @@ split at 4096 chars. IRC-only commands are no-ops. ~90% of plugins work.
# config/derp.toml # config/derp.toml
[mumble] [mumble]
enabled = true enabled = true
proxy = true # SOCKS5 proxy for TCP
host = "mumble.example.com" host = "mumble.example.com"
port = 64738 port = 64738
username = "derp" username = "derp"
@@ -537,7 +540,7 @@ operators = []
trusted = [] trusted = []
``` ```
TCP/TLS via SOCKS5 proxy. Text chat only (no voice). Minimal protobuf TCP/TLS via SOCKS5 proxy by default (`proxy = true`). Text chat only (no voice). Minimal protobuf
codec (no external dep). HTML stripped on receive, escaped on send. codec (no external dep). HTML stripped on receive, escaped on send.
IRC-only commands are no-ops. ~90% of plugins work. IRC-only commands are no-ops. ~90% of plugins work.

View File

@@ -32,6 +32,7 @@ All settings in `config/derp.toml`.
host = "irc.libera.chat" # IRC server hostname host = "irc.libera.chat" # IRC server hostname
port = 6697 # Port (6697 = TLS, 6667 = plain) port = 6697 # Port (6697 = TLS, 6667 = plain)
tls = true # Enable TLS encryption tls = true # Enable TLS encryption
proxy = false # Route through SOCKS5 proxy (default: false)
nick = "derp" # Bot nickname nick = "derp" # Bot nickname
user = "derp" # Username (ident) user = "derp" # Username (ident)
realname = "derp IRC bot" # Real name field realname = "derp IRC bot" # Real name field
@@ -1323,6 +1324,7 @@ required -- raw asyncio HTTP, same pattern as the webhook plugin.
```toml ```toml
[teams] [teams]
enabled = true enabled = true
proxy = true # Route outbound HTTP through SOCKS5
bot_name = "derp" # outgoing webhook display name bot_name = "derp" # outgoing webhook display name
bind = "127.0.0.1" # HTTP listen address bind = "127.0.0.1" # HTTP listen address
port = 8081 # HTTP listen port port = 8081 # HTTP listen port
@@ -1418,6 +1420,7 @@ the shared plugin registry. Replies are sent immediately via `sendMessage`.
```toml ```toml
[telegram] [telegram]
enabled = true enabled = true
proxy = true # Route HTTP through SOCKS5
bot_token = "123456:ABC-DEF..." # from @BotFather bot_token = "123456:ABC-DEF..." # from @BotFather
poll_timeout = 30 # long-poll timeout in seconds poll_timeout = 30 # long-poll timeout in seconds
admins = [123456789] # Telegram user IDs (numeric) admins = [123456789] # Telegram user IDs (numeric)
@@ -1472,19 +1475,20 @@ this automatically: `!help@mybot` becomes `!help`.
### Transport ### Transport
All HTTP traffic (API calls, long-polling) routes through the SOCKS5 All HTTP traffic (API calls, long-polling) routes through the SOCKS5
proxy at `127.0.0.1:1080` via `derp.http.urlopen`. No direct outbound proxy at `127.0.0.1:1080` via `derp.http.urlopen` when `proxy = true`
connections are made. (default). Set `proxy = false` to connect directly.
## Mumble Integration ## Mumble Integration
Connect derp to a Mumble server via TCP/TLS protobuf control channel. Connect derp to a Mumble server via TCP/TLS protobuf control channel.
Text chat only (no voice). All TCP is routed through the SOCKS5 proxy. Text chat only (no voice). TCP is routed through the SOCKS5 proxy when
No protobuf library dependency -- uses a minimal built-in varint/field `proxy = true` (default). No protobuf library dependency -- uses a
encoder/decoder for the ~7 message types needed. minimal built-in varint/field encoder/decoder for the ~7 message types
needed.
### How It Works ### How It Works
The bot connects to the Mumble server over TLS (via SOCKS5), sends The bot connects to the Mumble server over TLS, sends
Version and Authenticate messages, then enters a read loop. It tracks Version and Authenticate messages, then enters a read loop. It tracks
channels (ChannelState), users (UserState), and dispatches commands channels (ChannelState), users (UserState), and dispatches commands
from TextMessage messages through the shared plugin registry. from TextMessage messages through the shared plugin registry.
@@ -1494,6 +1498,7 @@ from TextMessage messages through the shared plugin registry.
```toml ```toml
[mumble] [mumble]
enabled = true enabled = true
proxy = true # Route TCP through SOCKS5
host = "mumble.example.com" # Mumble server hostname host = "mumble.example.com" # Mumble server hostname
port = 64738 # Default Mumble port port = 64738 # Default Mumble port
username = "derp" # Bot username username = "derp" # Bot username
@@ -1550,7 +1555,8 @@ unescapes entities. On send, text is HTML-escaped. Action messages use
### Transport ### Transport
All TCP connections route through the SOCKS5 proxy at `127.0.0.1:1080` TCP connections route through the SOCKS5 proxy at `127.0.0.1:1080`
via `derp.http.create_connection`. TLS is applied on top of the proxied via `derp.http.create_connection` when `proxy = true` (default). Set
`proxy = false` to connect directly. TLS is applied on top of the
socket. Mumble commonly uses self-signed certificates, so `tls_verify` socket. Mumble commonly uses self-signed certificates, so `tls_verify`
defaults to `false`. defaults to `false`.

View File

@@ -87,6 +87,7 @@ class Bot:
port=config["server"]["port"], port=config["server"]["port"],
tls=config["server"]["tls"], tls=config["server"]["tls"],
tls_verify=config["server"].get("tls_verify", True), tls_verify=config["server"].get("tls_verify", True),
proxy=config["server"].get("proxy", False),
) )
self.nick: str = config["server"]["nick"] self.nick: str = config["server"]["nick"]
self.prefix: str = config["bot"]["prefix"] self.prefix: str = config["bot"]["prefix"]

View File

@@ -10,6 +10,7 @@ DEFAULTS: dict = {
"host": "irc.libera.chat", "host": "irc.libera.chat",
"port": 6697, "port": 6697,
"tls": True, "tls": True,
"proxy": False,
"nick": "derp", "nick": "derp",
"user": "derp", "user": "derp",
"realname": "derp IRC bot", "realname": "derp IRC bot",
@@ -41,6 +42,7 @@ DEFAULTS: dict = {
}, },
"teams": { "teams": {
"enabled": False, "enabled": False,
"proxy": True,
"bot_name": "derp", "bot_name": "derp",
"bind": "127.0.0.1", "bind": "127.0.0.1",
"port": 8081, "port": 8081,
@@ -52,6 +54,7 @@ DEFAULTS: dict = {
}, },
"telegram": { "telegram": {
"enabled": False, "enabled": False,
"proxy": True,
"bot_token": "", "bot_token": "",
"poll_timeout": 30, "poll_timeout": 30,
"admins": [], "admins": [],
@@ -60,6 +63,7 @@ DEFAULTS: dict = {
}, },
"mumble": { "mumble": {
"enabled": False, "enabled": False,
"proxy": True,
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 64738, "port": 64738,
"username": "derp", "username": "derp",

View File

@@ -1,4 +1,4 @@
"""Proxy-aware HTTP/TCP helpers -- routes outbound traffic through SOCKS5.""" """HTTP/TCP helpers -- optional SOCKS5 proxy routing for outbound traffic."""
import asyncio import asyncio
import logging import logging
@@ -85,15 +85,20 @@ class _ProxyHandler(SocksiPyHandler, urllib.request.HTTPSHandler):
# -- Public HTTP interface --------------------------------------------------- # -- Public HTTP interface ---------------------------------------------------
def urlopen(req, *, timeout=None, context=None, retries=None): def urlopen(req, *, timeout=None, context=None, retries=None, proxy=True):
"""Proxy-aware drop-in for urllib.request.urlopen. """HTTP urlopen with optional SOCKS5 proxy.
Uses connection pooling via urllib3 for default requests. Uses connection pooling via urllib3 for proxied requests.
Falls back to legacy opener for custom SSL context. Falls back to legacy opener for custom SSL context.
When ``proxy=False``, uses stdlib ``urllib.request.urlopen`` directly.
Retries on transient SSL/connection errors with exponential backoff. Retries on transient SSL/connection errors with exponential backoff.
""" """
max_retries = retries if retries is not None else _MAX_RETRIES max_retries = retries if retries is not None else _MAX_RETRIES
# Direct (no proxy) path
if not proxy:
return _urlopen_direct(req, timeout=timeout, context=context, retries=max_retries)
# Custom SSL context -> fall back to opener (rare: username.py only) # Custom SSL context -> fall back to opener (rare: username.py only)
if context is not None: if context is not None:
return _urlopen_legacy(req, timeout=timeout, context=context, retries=max_retries) return _urlopen_legacy(req, timeout=timeout, context=context, retries=max_retries)
@@ -140,6 +145,26 @@ def urlopen(req, *, timeout=None, context=None, retries=None):
time.sleep(delay) time.sleep(delay)
def _urlopen_direct(req, *, timeout=None, context=None, retries=None):
"""Open URL directly without SOCKS5 proxy."""
max_retries = retries if retries is not None else _MAX_RETRIES
kwargs = {}
if timeout is not None:
kwargs["timeout"] = timeout
if context is not None:
kwargs["context"] = context
for attempt in range(max_retries):
try:
return urllib.request.urlopen(req, **kwargs)
except _RETRY_ERRORS as exc:
if attempt + 1 >= max_retries:
raise
delay = 2 ** attempt
_log.debug("urlopen_direct retry %d/%d after %s: %s",
attempt + 1, max_retries, type(exc).__name__, exc)
time.sleep(delay)
def _urlopen_legacy(req, *, timeout=None, context=None, retries=None): def _urlopen_legacy(req, *, timeout=None, context=None, retries=None):
"""Open URL through legacy opener (custom SSL context).""" """Open URL through legacy opener (custom SSL context)."""
max_retries = retries if retries is not None else _MAX_RETRIES max_retries = retries if retries is not None else _MAX_RETRIES
@@ -159,27 +184,32 @@ def _urlopen_legacy(req, *, timeout=None, context=None, retries=None):
time.sleep(delay) time.sleep(delay)
def build_opener(*handlers, context=None): def build_opener(*handlers, context=None, proxy=True):
"""Proxy-aware drop-in for urllib.request.build_opener.""" """Build a URL opener, optionally with SOCKS5 proxy."""
if not proxy:
return urllib.request.build_opener(*handlers)
if not handlers and context is None: if not handlers and context is None:
return _get_opener() return _get_opener()
proxy = _ProxyHandler(context=context) proxy_handler = _ProxyHandler(context=context)
return urllib.request.build_opener(proxy, *handlers) return urllib.request.build_opener(proxy_handler, *handlers)
# -- Raw TCP helpers (unchanged) --------------------------------------------- # -- Raw TCP helpers (unchanged) ---------------------------------------------
def create_connection(address, *, timeout=None): def create_connection(address, *, timeout=None, proxy=True):
"""SOCKS5-proxied drop-in for socket.create_connection. """Drop-in for socket.create_connection, optionally through SOCKS5.
Returns a connected socksocket (usable as context manager). Returns a connected socket (usable as context manager).
Retries on transient connection errors with exponential backoff. Retries on transient connection errors with exponential backoff.
""" """
host, port = address host, port = address
for attempt in range(_MAX_RETRIES): for attempt in range(_MAX_RETRIES):
try: try:
sock = socks.socksocket() if proxy:
sock.set_proxy(SOCKS5, _PROXY_ADDR, _PROXY_PORT, rdns=True) sock = socks.socksocket()
sock.set_proxy(SOCKS5, _PROXY_ADDR, _PROXY_PORT, rdns=True)
else:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
if timeout is not None: if timeout is not None:
sock.settimeout(timeout) sock.settimeout(timeout)
sock.connect((host, port)) sock.connect((host, port))
@@ -193,12 +223,27 @@ def create_connection(address, *, timeout=None):
time.sleep(delay) time.sleep(delay)
async def open_connection(host, port, *, timeout=None): async def open_connection(host, port, *, timeout=None, proxy=True):
"""SOCKS5-proxied drop-in for asyncio.open_connection. """Async TCP connection, optionally through SOCKS5.
SOCKS5 handshake runs in a thread executor; returns (reader, writer). When proxied, SOCKS5 handshake runs in a thread executor.
Returns (reader, writer).
Retries on transient connection errors with exponential backoff. Retries on transient connection errors with exponential backoff.
""" """
if not proxy:
# Direct asyncio connection
for attempt in range(_MAX_RETRIES):
try:
return await asyncio.open_connection(host, port)
except _RETRY_ERRORS as exc:
if attempt + 1 >= _MAX_RETRIES:
raise
delay = 2 ** attempt
_log.debug("open_connection retry %d/%d after %s: %s",
attempt + 1, _MAX_RETRIES, type(exc).__name__, exc)
await asyncio.sleep(delay)
return # unreachable but satisfies type checker
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
def _connect(): def _connect():

View File

@@ -137,11 +137,12 @@ class IRCConnection:
"""Async TCP/TLS connection to an IRC server.""" """Async TCP/TLS connection to an IRC server."""
def __init__(self, host: str, port: int, tls: bool = True, def __init__(self, host: str, port: int, tls: bool = True,
tls_verify: bool = True) -> None: tls_verify: bool = True, proxy: bool = False) -> None:
self.host = host self.host = host
self.port = port self.port = port
self.tls = tls self.tls = tls
self.tls_verify = tls_verify self.tls_verify = tls_verify
self.proxy = proxy
self._reader: asyncio.StreamReader | None = None self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None self._writer: asyncio.StreamWriter | None = None
@@ -154,10 +155,26 @@ class IRCConnection:
ssl_ctx.check_hostname = False ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE ssl_ctx.verify_mode = ssl.CERT_NONE
log.info("connecting to %s:%d (tls=%s)", self.host, self.port, self.tls) log.info("connecting to %s:%d (tls=%s, proxy=%s)",
self._reader, self._writer = await asyncio.open_connection( self.host, self.port, self.tls, self.proxy)
self.host, self.port, ssl=ssl_ctx if self.proxy:
) from derp import http
reader, writer = await http.open_connection(
self.host, self.port,
)
if self.tls:
hostname = self.host if self.tls_verify else None
self._reader, self._writer = await asyncio.open_connection(
sock=writer.transport.get_extra_info("socket"),
ssl=ssl_ctx,
server_hostname=hostname,
)
else:
self._reader, self._writer = reader, writer
else:
self._reader, self._writer = await asyncio.open_connection(
self.host, self.port, ssl=ssl_ctx,
)
log.info("connected") log.info("connected")
async def send(self, line: str) -> None: async def send(self, line: str) -> None:

View File

@@ -278,7 +278,8 @@ class MumbleBot:
Exposes the same public API as :class:`derp.bot.Bot` so that Exposes the same public API as :class:`derp.bot.Bot` so that
protocol-agnostic plugins work without modification. protocol-agnostic plugins work without modification.
All TCP goes through ``derp.http.create_connection`` (SOCKS5 proxy). TCP is routed through ``derp.http.create_connection`` (SOCKS5
optional via ``mumble.proxy`` config).
""" """
def __init__(self, name: str, config: dict, registry: PluginRegistry) -> None: def __init__(self, name: str, config: dict, registry: PluginRegistry) -> None:
@@ -288,6 +289,7 @@ class MumbleBot:
self._pstate: dict = {} self._pstate: dict = {}
mu_cfg = config.get("mumble", {}) mu_cfg = config.get("mumble", {})
self._proxy: bool = mu_cfg.get("proxy", True)
self._host: str = mu_cfg.get("host", "127.0.0.1") self._host: str = mu_cfg.get("host", "127.0.0.1")
self._port: int = mu_cfg.get("port", 64738) self._port: int = mu_cfg.get("port", 64738)
self._username: str = mu_cfg.get("username", "derp") self._username: str = mu_cfg.get("username", "derp")
@@ -332,11 +334,12 @@ class MumbleBot:
return ctx return ctx
async def _connect(self) -> None: async def _connect(self) -> None:
"""Establish TLS connection over SOCKS5 proxy.""" """Establish TLS connection, optionally through SOCKS5 proxy."""
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
sock = await loop.run_in_executor( sock = await loop.run_in_executor(
None, http.create_connection, None, lambda: http.create_connection(
(self._host, self._port), (self._host, self._port), proxy=self._proxy,
),
) )
ssl_ctx = self._create_ssl_context() ssl_ctx = self._create_ssl_context()
hostname = self._host if self._tls_verify else None hostname = self._host if self._tls_verify else None

View File

@@ -143,6 +143,7 @@ class TeamsBot:
self._pstate: dict = {} self._pstate: dict = {}
teams_cfg = config.get("teams", {}) teams_cfg = config.get("teams", {})
self._proxy: bool = teams_cfg.get("proxy", True)
self.nick: str = teams_cfg.get("bot_name", "derp") self.nick: str = teams_cfg.get("bot_name", "derp")
self.prefix: str = config.get("bot", {}).get("prefix", "!") self.prefix: str = config.get("bot", {}).get("prefix", "!")
self._running = False self._running = False
@@ -390,7 +391,9 @@ class TeamsBot:
) )
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
try: try:
await loop.run_in_executor(None, http.urlopen, req) await loop.run_in_executor(
None, lambda: http.urlopen(req, proxy=self._proxy),
)
except Exception: except Exception:
log.exception("teams: failed to send via incoming webhook") log.exception("teams: failed to send via incoming webhook")

View File

@@ -132,7 +132,8 @@ class TelegramBot:
Exposes the same public API as :class:`derp.bot.Bot` so that Exposes the same public API as :class:`derp.bot.Bot` so that
protocol-agnostic plugins work without modification. protocol-agnostic plugins work without modification.
All HTTP goes through ``derp.http.urlopen`` (SOCKS5 proxy). HTTP is routed through ``derp.http.urlopen`` (SOCKS5 optional
via ``telegram.proxy`` config).
""" """
def __init__(self, name: str, config: dict, registry: PluginRegistry) -> None: def __init__(self, name: str, config: dict, registry: PluginRegistry) -> None:
@@ -144,6 +145,7 @@ class TelegramBot:
tg_cfg = config.get("telegram", {}) tg_cfg = config.get("telegram", {})
self._token: str = tg_cfg.get("bot_token", "") self._token: str = tg_cfg.get("bot_token", "")
self._poll_timeout: int = tg_cfg.get("poll_timeout", 30) self._poll_timeout: int = tg_cfg.get("poll_timeout", 30)
self._proxy: bool = tg_cfg.get("proxy", True)
self.nick: str = "" # set by getMe self.nick: str = "" # set by getMe
self._bot_username: str = "" # set by getMe self._bot_username: str = "" # set by getMe
self.prefix: str = ( self.prefix: str = (
@@ -188,7 +190,7 @@ class TelegramBot:
req = urllib.request.Request(url, method="GET") req = urllib.request.Request(url, method="GET")
timeout = self._poll_timeout + 5 if method == "getUpdates" else 30 timeout = self._poll_timeout + 5 if method == "getUpdates" else 30
resp = http.urlopen(req, timeout=timeout) resp = http.urlopen(req, timeout=timeout, proxy=self._proxy)
body = resp.read() if hasattr(resp, "read") else resp.data body = resp.read() if hasattr(resp, "read") else resp.data
return json.loads(body) return json.loads(body)

View File

@@ -280,3 +280,19 @@ class TestBuildServerConfigs:
assert len(result) == 1 assert len(result) == 1
name = list(result.keys())[0] name = list(result.keys())[0]
assert result[name] is raw 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 ssl
import urllib.error import urllib.error
import urllib.request import urllib.request
@@ -267,3 +268,106 @@ class TestCreateConnection:
mock_cls.return_value = sock mock_cls.return_value = sock
result = create_connection(("example.com", 443)) result = create_connection(("example.com", 443))
assert result is sock 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.""" """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: class TestParse:
@@ -142,3 +148,19 @@ class TestFormat:
# No space in tail, not starting with colon, head exists -> no colon # No space in tail, not starting with colon, head exists -> no colon
result = format_msg("MODE", "#ch", "+o", "nick") result = format_msg("MODE", "#ch", "+o", "nick")
assert result == "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): def test_nick_from_username(self):
bot = _make_bot() bot = _make_bot()
assert bot.nick == "derp" 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") ok, msg = bot.reload_plugin("nonexistent")
assert ok is False assert ok is False
assert "not loaded" in msg 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): def test_admins_coerced_to_str(self):
bot = _make_bot(admins=[111, 222]) bot = _make_bot(admins=[111, 222])
assert 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