From 1bdba0ea0650abd8ffe368c36bd22c8960f3ca00 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 15 Feb 2026 16:01:17 +0100 Subject: [PATCH] feat: route raw TCP traffic through SOCKS5 proxy Add create_connection and open_connection helpers to the shared proxy module, covering portcheck, whois, tlscheck, and crtsh live-cert check. UDP-based plugins (dns, blacklist, subdomain) stay direct. Co-Authored-By: Claude Opus 4.6 --- plugins/crtsh.py | 4 ++-- plugins/portcheck.py | 5 +++-- plugins/tlscheck.py | 6 +++--- plugins/whois.py | 5 +++-- src/derp/http.py | 38 ++++++++++++++++++++++++++++++++- tests/test_http.py | 51 ++++++++++++++++++++++++++++++++++++++++++-- 6 files changed, 97 insertions(+), 12 deletions(-) diff --git a/plugins/crtsh.py b/plugins/crtsh.py index 7ea1547..6fe8082 100644 --- a/plugins/crtsh.py +++ b/plugins/crtsh.py @@ -7,12 +7,12 @@ totals (expired/valid), and flag domains still serving expired certs. import asyncio import json import logging -import socket import ssl import urllib.request from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone +from derp.http import create_connection as _create_connection from derp.http import urlopen as _urlopen from derp.plugin import command @@ -43,7 +43,7 @@ def check_live_cert(domain: str) -> dict | None: for ctx_factory in (_make_verified_ctx, _make_unverified_ctx): ctx = ctx_factory() try: - with socket.create_connection((domain, 443), timeout=10) as sock: + with _create_connection((domain, 443), timeout=10) as sock: with ctx.wrap_socket(sock, server_hostname=domain) as ssock: return ssock.getpeercert() except (OSError, ssl.SSLError): diff --git a/plugins/portcheck.py b/plugins/portcheck.py index b0a565d..67d95d2 100644 --- a/plugins/portcheck.py +++ b/plugins/portcheck.py @@ -1,4 +1,4 @@ -"""Plugin: async TCP port scanner (pure stdlib).""" +"""Plugin: async TCP port scanner (SOCKS5-proxied).""" from __future__ import annotations @@ -6,6 +6,7 @@ import asyncio import ipaddress import time +from derp.http import open_connection as _open_connection from derp.plugin import command _TIMEOUT = 3.0 @@ -49,7 +50,7 @@ async def _check_port(host: str, port: int, timeout: float) -> tuple[int, bool, t0 = time.monotonic() try: _, writer = await asyncio.wait_for( - asyncio.open_connection(host, port), timeout=timeout, + _open_connection(host, port, timeout=timeout), timeout=timeout, ) rtt = (time.monotonic() - t0) * 1000 writer.close() diff --git a/plugins/tlscheck.py b/plugins/tlscheck.py index 4359021..b1009e3 100644 --- a/plugins/tlscheck.py +++ b/plugins/tlscheck.py @@ -1,13 +1,13 @@ -"""Plugin: TLS certificate and cipher inspector (pure stdlib).""" +"""Plugin: TLS certificate and cipher inspector (SOCKS5-proxied).""" from __future__ import annotations import asyncio import hashlib -import socket import ssl from datetime import datetime, timezone +from derp.http import create_connection as _create_connection from derp.plugin import command _TIMEOUT = 10 @@ -35,7 +35,7 @@ def _inspect(host: str, port: int) -> dict: ctx.verify_mode = ssl.CERT_NONE try: - with socket.create_connection((host, port), timeout=_TIMEOUT) as sock: + with _create_connection((host, port), timeout=_TIMEOUT) as sock: with ctx.wrap_socket(sock, server_hostname=host) as ssock: result["version"] = ssock.version() or "" cipher = ssock.cipher() diff --git a/plugins/whois.py b/plugins/whois.py index 93b5160..fc96da0 100644 --- a/plugins/whois.py +++ b/plugins/whois.py @@ -1,10 +1,11 @@ -"""Plugin: WHOIS lookup (raw TCP, port 43, pure stdlib).""" +"""Plugin: WHOIS lookup (raw TCP, port 43, SOCKS5-proxied).""" from __future__ import annotations import asyncio import ipaddress +from derp.http import open_connection as _open_connection from derp.plugin import command # Referral servers for common TLDs @@ -51,7 +52,7 @@ def _pick_server(target: str) -> tuple[str, str]: async def _whois(server: str, query: str) -> str: """Send a WHOIS query and return the response text.""" reader, writer = await asyncio.wait_for( - asyncio.open_connection(server, 43), timeout=_TIMEOUT, + _open_connection(server, 43, timeout=_TIMEOUT), timeout=_TIMEOUT, ) try: writer.write(f"{query}\r\n".encode("utf-8")) diff --git a/src/derp/http.py b/src/derp/http.py index b6f8db6..315e53f 100644 --- a/src/derp/http.py +++ b/src/derp/http.py @@ -1,9 +1,11 @@ -"""Proxy-aware HTTP helpers -- routes outbound traffic through SOCKS5.""" +"""Proxy-aware HTTP/TCP helpers -- routes outbound traffic through SOCKS5.""" +import asyncio import socket import ssl import urllib.request +import socks from socks import SOCKS5 from sockshandler import SocksiPyConnectionS, SocksiPyHandler @@ -46,3 +48,37 @@ def build_opener(*handlers, context=None): """Proxy-aware drop-in for urllib.request.build_opener.""" proxy = _ProxyHandler(context=context) return urllib.request.build_opener(proxy, *handlers) + + +def create_connection(address, *, timeout=None): + """SOCKS5-proxied drop-in for socket.create_connection. + + Returns a connected socksocket (usable as context manager). + """ + host, port = address + sock = socks.socksocket() + sock.set_proxy(SOCKS5, _PROXY_ADDR, _PROXY_PORT, rdns=True) + if timeout is not None: + sock.settimeout(timeout) + sock.connect((host, port)) + return sock + + +async def open_connection(host, port, *, timeout=None): + """SOCKS5-proxied drop-in for asyncio.open_connection. + + SOCKS5 handshake runs in a thread executor; returns (reader, writer). + """ + loop = asyncio.get_running_loop() + + def _connect(): + sock = socks.socksocket() + sock.set_proxy(SOCKS5, _PROXY_ADDR, _PROXY_PORT, rdns=True) + if timeout is not None: + sock.settimeout(timeout) + sock.connect((host, port)) + sock.setblocking(False) + return sock + + sock = await loop.run_in_executor(None, _connect) + return await asyncio.open_connection(sock=sock) diff --git a/tests/test_http.py b/tests/test_http.py index 9ff9f09..198f100 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -1,11 +1,19 @@ -"""Tests for the SOCKS5 proxy HTTP module.""" +"""Tests for the SOCKS5 proxy HTTP/TCP module.""" import ssl import urllib.request +from unittest.mock import MagicMock, patch +import socks from socks import SOCKS5 -from derp.http import _PROXY_ADDR, _PROXY_PORT, _ProxyHandler, build_opener +from derp.http import ( + _PROXY_ADDR, + _PROXY_PORT, + _ProxyHandler, + build_opener, + create_connection, +) class TestProxyHandler: @@ -57,3 +65,42 @@ class TestBuildOpener: opener = build_opener(context=ctx) proxy = [h for h in opener.handlers if isinstance(h, _ProxyHandler)][0] assert proxy._ssl_context is ctx + + +class TestCreateConnection: + @patch("derp.http.socks.socksocket") + def test_sets_socks5_proxy(self, mock_cls): + sock = MagicMock() + mock_cls.return_value = sock + create_connection(("example.com", 443), timeout=5) + sock.set_proxy.assert_called_once_with( + SOCKS5, _PROXY_ADDR, _PROXY_PORT, rdns=True, + ) + + @patch("derp.http.socks.socksocket") + def test_connects_to_target(self, mock_cls): + sock = MagicMock() + mock_cls.return_value = sock + create_connection(("example.com", 443)) + sock.connect.assert_called_once_with(("example.com", 443)) + + @patch("derp.http.socks.socksocket") + def test_sets_timeout(self, mock_cls): + sock = MagicMock() + mock_cls.return_value = sock + create_connection(("example.com", 80), timeout=7) + sock.settimeout.assert_called_once_with(7) + + @patch("derp.http.socks.socksocket") + def test_no_timeout_when_none(self, mock_cls): + sock = MagicMock() + mock_cls.return_value = sock + create_connection(("example.com", 80)) + sock.settimeout.assert_not_called() + + @patch("derp.http.socks.socksocket") + def test_returns_socket(self, mock_cls): + sock = MagicMock() + mock_cls.return_value = sock + result = create_connection(("example.com", 443)) + assert result is sock