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:
15
TASKS.md
15
TASKS.md
@@ -1,6 +1,19 @@
|
||||
# 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 |
|
||||
|-----|--------|------|
|
||||
|
||||
11
docs/API.md
11
docs/API.md
@@ -189,14 +189,15 @@ SQLite-backed key-value store. Each plugin gets its own namespace.
|
||||
|
||||
## `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 |
|
||||
|----------|-----------|-------------|
|
||||
| `urlopen` | `(req, *, timeout=None, context=None, retries=None)` | Proxy-aware HTTP request with connection pooling and retries |
|
||||
| `build_opener` | `(*handlers, context=None)` | Proxy-aware `urllib.request.build_opener` replacement |
|
||||
| `create_connection` | `(address, *, timeout=None)` | SOCKS5-proxied `socket.create_connection` with retries |
|
||||
| `open_connection` | `(host, port, *, timeout=None)` | SOCKS5-proxied `asyncio.open_connection` with 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=True)` | Build URL opener, optionally with SOCKS5 handler |
|
||||
| `create_connection` | `(address, *, timeout=None, proxy=True)` | TCP `socket.create_connection` with optional SOCKS5, retries |
|
||||
| `open_connection` | `(host, port, *, timeout=None, proxy=True)` | Async `asyncio.open_connection` with optional SOCKS5, retries |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -488,6 +488,7 @@ Auth: HMAC-SHA256 via `X-Signature` header. Starts on IRC connect.
|
||||
# config/derp.toml
|
||||
[teams]
|
||||
enabled = true
|
||||
proxy = true # SOCKS5 proxy for outbound HTTP
|
||||
bot_name = "derp"
|
||||
bind = "127.0.0.1"
|
||||
port = 8081
|
||||
@@ -510,6 +511,7 @@ Replies returned as JSON in HTTP response. IRC-only commands (kick, ban, topic)
|
||||
# config/derp.toml
|
||||
[telegram]
|
||||
enabled = true
|
||||
proxy = true # SOCKS5 proxy for HTTP
|
||||
bot_token = "123456:ABC-DEF..." # from @BotFather
|
||||
poll_timeout = 30 # long-poll seconds
|
||||
admins = [123456789] # Telegram user IDs
|
||||
@@ -517,8 +519,8 @@ operators = []
|
||||
trusted = []
|
||||
```
|
||||
|
||||
Long-polling via `getUpdates` -- no public endpoint needed. All HTTP
|
||||
through SOCKS5 proxy. Strips `@botusername` suffix in groups. Messages
|
||||
Long-polling via `getUpdates` -- no public endpoint needed. HTTP through
|
||||
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.
|
||||
|
||||
## Mumble Integration
|
||||
@@ -527,6 +529,7 @@ split at 4096 chars. IRC-only commands are no-ops. ~90% of plugins work.
|
||||
# config/derp.toml
|
||||
[mumble]
|
||||
enabled = true
|
||||
proxy = true # SOCKS5 proxy for TCP
|
||||
host = "mumble.example.com"
|
||||
port = 64738
|
||||
username = "derp"
|
||||
@@ -537,7 +540,7 @@ operators = []
|
||||
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.
|
||||
IRC-only commands are no-ops. ~90% of plugins work.
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ All settings in `config/derp.toml`.
|
||||
host = "irc.libera.chat" # IRC server hostname
|
||||
port = 6697 # Port (6697 = TLS, 6667 = plain)
|
||||
tls = true # Enable TLS encryption
|
||||
proxy = false # Route through SOCKS5 proxy (default: false)
|
||||
nick = "derp" # Bot nickname
|
||||
user = "derp" # Username (ident)
|
||||
realname = "derp IRC bot" # Real name field
|
||||
@@ -1323,6 +1324,7 @@ required -- raw asyncio HTTP, same pattern as the webhook plugin.
|
||||
```toml
|
||||
[teams]
|
||||
enabled = true
|
||||
proxy = true # Route outbound HTTP through SOCKS5
|
||||
bot_name = "derp" # outgoing webhook display name
|
||||
bind = "127.0.0.1" # HTTP listen address
|
||||
port = 8081 # HTTP listen port
|
||||
@@ -1418,6 +1420,7 @@ the shared plugin registry. Replies are sent immediately via `sendMessage`.
|
||||
```toml
|
||||
[telegram]
|
||||
enabled = true
|
||||
proxy = true # Route HTTP through SOCKS5
|
||||
bot_token = "123456:ABC-DEF..." # from @BotFather
|
||||
poll_timeout = 30 # long-poll timeout in seconds
|
||||
admins = [123456789] # Telegram user IDs (numeric)
|
||||
@@ -1472,19 +1475,20 @@ this automatically: `!help@mybot` becomes `!help`.
|
||||
### Transport
|
||||
|
||||
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
|
||||
connections are made.
|
||||
proxy at `127.0.0.1:1080` via `derp.http.urlopen` when `proxy = true`
|
||||
(default). Set `proxy = false` to connect directly.
|
||||
|
||||
## Mumble Integration
|
||||
|
||||
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.
|
||||
No protobuf library dependency -- uses a minimal built-in varint/field
|
||||
encoder/decoder for the ~7 message types needed.
|
||||
Text chat only (no voice). TCP is routed through the SOCKS5 proxy when
|
||||
`proxy = true` (default). No protobuf library dependency -- uses a
|
||||
minimal built-in varint/field encoder/decoder for the ~7 message types
|
||||
needed.
|
||||
|
||||
### 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
|
||||
channels (ChannelState), users (UserState), and dispatches commands
|
||||
from TextMessage messages through the shared plugin registry.
|
||||
@@ -1494,6 +1498,7 @@ from TextMessage messages through the shared plugin registry.
|
||||
```toml
|
||||
[mumble]
|
||||
enabled = true
|
||||
proxy = true # Route TCP through SOCKS5
|
||||
host = "mumble.example.com" # Mumble server hostname
|
||||
port = 64738 # Default Mumble port
|
||||
username = "derp" # Bot username
|
||||
@@ -1550,7 +1555,8 @@ unescapes entities. On send, text is HTML-escaped. Action messages use
|
||||
|
||||
### Transport
|
||||
|
||||
All 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
|
||||
TCP connections route through the SOCKS5 proxy at `127.0.0.1:1080`
|
||||
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`
|
||||
defaults to `false`.
|
||||
|
||||
@@ -87,6 +87,7 @@ class Bot:
|
||||
port=config["server"]["port"],
|
||||
tls=config["server"]["tls"],
|
||||
tls_verify=config["server"].get("tls_verify", True),
|
||||
proxy=config["server"].get("proxy", False),
|
||||
)
|
||||
self.nick: str = config["server"]["nick"]
|
||||
self.prefix: str = config["bot"]["prefix"]
|
||||
|
||||
@@ -10,6 +10,7 @@ DEFAULTS: dict = {
|
||||
"host": "irc.libera.chat",
|
||||
"port": 6697,
|
||||
"tls": True,
|
||||
"proxy": False,
|
||||
"nick": "derp",
|
||||
"user": "derp",
|
||||
"realname": "derp IRC bot",
|
||||
@@ -41,6 +42,7 @@ DEFAULTS: dict = {
|
||||
},
|
||||
"teams": {
|
||||
"enabled": False,
|
||||
"proxy": True,
|
||||
"bot_name": "derp",
|
||||
"bind": "127.0.0.1",
|
||||
"port": 8081,
|
||||
@@ -52,6 +54,7 @@ DEFAULTS: dict = {
|
||||
},
|
||||
"telegram": {
|
||||
"enabled": False,
|
||||
"proxy": True,
|
||||
"bot_token": "",
|
||||
"poll_timeout": 30,
|
||||
"admins": [],
|
||||
@@ -60,6 +63,7 @@ DEFAULTS: dict = {
|
||||
},
|
||||
"mumble": {
|
||||
"enabled": False,
|
||||
"proxy": True,
|
||||
"host": "127.0.0.1",
|
||||
"port": 64738,
|
||||
"username": "derp",
|
||||
|
||||
@@ -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 logging
|
||||
@@ -85,15 +85,20 @@ class _ProxyHandler(SocksiPyHandler, urllib.request.HTTPSHandler):
|
||||
|
||||
# -- Public HTTP interface ---------------------------------------------------
|
||||
|
||||
def urlopen(req, *, timeout=None, context=None, retries=None):
|
||||
"""Proxy-aware drop-in for urllib.request.urlopen.
|
||||
def urlopen(req, *, timeout=None, context=None, retries=None, proxy=True):
|
||||
"""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.
|
||||
When ``proxy=False``, uses stdlib ``urllib.request.urlopen`` directly.
|
||||
Retries on transient SSL/connection errors with exponential backoff.
|
||||
"""
|
||||
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)
|
||||
if context is not None:
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""Open URL through legacy opener (custom SSL context)."""
|
||||
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)
|
||||
|
||||
|
||||
def build_opener(*handlers, context=None):
|
||||
"""Proxy-aware drop-in for urllib.request.build_opener."""
|
||||
def build_opener(*handlers, context=None, proxy=True):
|
||||
"""Build a URL opener, optionally with SOCKS5 proxy."""
|
||||
if not proxy:
|
||||
return urllib.request.build_opener(*handlers)
|
||||
if not handlers and context is None:
|
||||
return _get_opener()
|
||||
proxy = _ProxyHandler(context=context)
|
||||
return urllib.request.build_opener(proxy, *handlers)
|
||||
proxy_handler = _ProxyHandler(context=context)
|
||||
return urllib.request.build_opener(proxy_handler, *handlers)
|
||||
|
||||
|
||||
# -- Raw TCP helpers (unchanged) ---------------------------------------------
|
||||
|
||||
def create_connection(address, *, timeout=None):
|
||||
"""SOCKS5-proxied drop-in for socket.create_connection.
|
||||
def create_connection(address, *, timeout=None, proxy=True):
|
||||
"""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.
|
||||
"""
|
||||
host, port = address
|
||||
for attempt in range(_MAX_RETRIES):
|
||||
try:
|
||||
if proxy:
|
||||
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:
|
||||
sock.settimeout(timeout)
|
||||
sock.connect((host, port))
|
||||
@@ -193,12 +223,27 @@ def create_connection(address, *, timeout=None):
|
||||
time.sleep(delay)
|
||||
|
||||
|
||||
async def open_connection(host, port, *, timeout=None):
|
||||
"""SOCKS5-proxied drop-in for asyncio.open_connection.
|
||||
async def open_connection(host, port, *, timeout=None, proxy=True):
|
||||
"""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.
|
||||
"""
|
||||
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()
|
||||
|
||||
def _connect():
|
||||
|
||||
@@ -137,11 +137,12 @@ class IRCConnection:
|
||||
"""Async TCP/TLS connection to an IRC server."""
|
||||
|
||||
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.port = port
|
||||
self.tls = tls
|
||||
self.tls_verify = tls_verify
|
||||
self.proxy = proxy
|
||||
self._reader: asyncio.StreamReader | None = None
|
||||
self._writer: asyncio.StreamWriter | None = None
|
||||
|
||||
@@ -154,9 +155,25 @@ class IRCConnection:
|
||||
ssl_ctx.check_hostname = False
|
||||
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.host, self.port, self.tls, self.proxy)
|
||||
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(
|
||||
self.host, self.port, ssl=ssl_ctx
|
||||
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")
|
||||
|
||||
|
||||
@@ -278,7 +278,8 @@ class MumbleBot:
|
||||
|
||||
Exposes the same public API as :class:`derp.bot.Bot` so that
|
||||
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:
|
||||
@@ -288,6 +289,7 @@ class MumbleBot:
|
||||
self._pstate: dict = {}
|
||||
|
||||
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._port: int = mu_cfg.get("port", 64738)
|
||||
self._username: str = mu_cfg.get("username", "derp")
|
||||
@@ -332,11 +334,12 @@ class MumbleBot:
|
||||
return ctx
|
||||
|
||||
async def _connect(self) -> None:
|
||||
"""Establish TLS connection over SOCKS5 proxy."""
|
||||
"""Establish TLS connection, optionally through SOCKS5 proxy."""
|
||||
loop = asyncio.get_running_loop()
|
||||
sock = await loop.run_in_executor(
|
||||
None, http.create_connection,
|
||||
(self._host, self._port),
|
||||
None, lambda: http.create_connection(
|
||||
(self._host, self._port), proxy=self._proxy,
|
||||
),
|
||||
)
|
||||
ssl_ctx = self._create_ssl_context()
|
||||
hostname = self._host if self._tls_verify else None
|
||||
|
||||
@@ -143,6 +143,7 @@ class TeamsBot:
|
||||
self._pstate: dict = {}
|
||||
|
||||
teams_cfg = config.get("teams", {})
|
||||
self._proxy: bool = teams_cfg.get("proxy", True)
|
||||
self.nick: str = teams_cfg.get("bot_name", "derp")
|
||||
self.prefix: str = config.get("bot", {}).get("prefix", "!")
|
||||
self._running = False
|
||||
@@ -390,7 +391,9 @@ class TeamsBot:
|
||||
)
|
||||
loop = asyncio.get_running_loop()
|
||||
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:
|
||||
log.exception("teams: failed to send via incoming webhook")
|
||||
|
||||
|
||||
@@ -132,7 +132,8 @@ class TelegramBot:
|
||||
|
||||
Exposes the same public API as :class:`derp.bot.Bot` so that
|
||||
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:
|
||||
@@ -144,6 +145,7 @@ class TelegramBot:
|
||||
tg_cfg = config.get("telegram", {})
|
||||
self._token: str = tg_cfg.get("bot_token", "")
|
||||
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._bot_username: str = "" # set by getMe
|
||||
self.prefix: str = (
|
||||
@@ -188,7 +190,7 @@ class TelegramBot:
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
|
||||
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
|
||||
return json.loads(body)
|
||||
|
||||
|
||||
@@ -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