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 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-15 16:01:17 +01:00
parent 97bbc6a825
commit 1bdba0ea06
6 changed files with 97 additions and 12 deletions

View File

@@ -7,12 +7,12 @@ totals (expired/valid), and flag domains still serving expired certs.
import asyncio import asyncio
import json import json
import logging import logging
import socket
import ssl import ssl
import urllib.request import urllib.request
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone from datetime import datetime, timezone
from derp.http import create_connection as _create_connection
from derp.http import urlopen as _urlopen from derp.http import urlopen as _urlopen
from derp.plugin import command 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): for ctx_factory in (_make_verified_ctx, _make_unverified_ctx):
ctx = ctx_factory() ctx = ctx_factory()
try: 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: with ctx.wrap_socket(sock, server_hostname=domain) as ssock:
return ssock.getpeercert() return ssock.getpeercert()
except (OSError, ssl.SSLError): except (OSError, ssl.SSLError):

View File

@@ -1,4 +1,4 @@
"""Plugin: async TCP port scanner (pure stdlib).""" """Plugin: async TCP port scanner (SOCKS5-proxied)."""
from __future__ import annotations from __future__ import annotations
@@ -6,6 +6,7 @@ import asyncio
import ipaddress import ipaddress
import time import time
from derp.http import open_connection as _open_connection
from derp.plugin import command from derp.plugin import command
_TIMEOUT = 3.0 _TIMEOUT = 3.0
@@ -49,7 +50,7 @@ async def _check_port(host: str, port: int, timeout: float) -> tuple[int, bool,
t0 = time.monotonic() t0 = time.monotonic()
try: try:
_, writer = await asyncio.wait_for( _, 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 rtt = (time.monotonic() - t0) * 1000
writer.close() writer.close()

View File

@@ -1,13 +1,13 @@
"""Plugin: TLS certificate and cipher inspector (pure stdlib).""" """Plugin: TLS certificate and cipher inspector (SOCKS5-proxied)."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
import socket
import ssl import ssl
from datetime import datetime, timezone from datetime import datetime, timezone
from derp.http import create_connection as _create_connection
from derp.plugin import command from derp.plugin import command
_TIMEOUT = 10 _TIMEOUT = 10
@@ -35,7 +35,7 @@ def _inspect(host: str, port: int) -> dict:
ctx.verify_mode = ssl.CERT_NONE ctx.verify_mode = ssl.CERT_NONE
try: 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: with ctx.wrap_socket(sock, server_hostname=host) as ssock:
result["version"] = ssock.version() or "" result["version"] = ssock.version() or ""
cipher = ssock.cipher() cipher = ssock.cipher()

View File

@@ -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 from __future__ import annotations
import asyncio import asyncio
import ipaddress import ipaddress
from derp.http import open_connection as _open_connection
from derp.plugin import command from derp.plugin import command
# Referral servers for common TLDs # 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: async def _whois(server: str, query: str) -> str:
"""Send a WHOIS query and return the response text.""" """Send a WHOIS query and return the response text."""
reader, writer = await asyncio.wait_for( reader, writer = await asyncio.wait_for(
asyncio.open_connection(server, 43), timeout=_TIMEOUT, _open_connection(server, 43, timeout=_TIMEOUT), timeout=_TIMEOUT,
) )
try: try:
writer.write(f"{query}\r\n".encode("utf-8")) writer.write(f"{query}\r\n".encode("utf-8"))

View File

@@ -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 socket
import ssl import ssl
import urllib.request import urllib.request
import socks
from socks import SOCKS5 from socks import SOCKS5
from sockshandler import SocksiPyConnectionS, SocksiPyHandler 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-aware drop-in for urllib.request.build_opener."""
proxy = _ProxyHandler(context=context) proxy = _ProxyHandler(context=context)
return urllib.request.build_opener(proxy, *handlers) 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)

View File

@@ -1,11 +1,19 @@
"""Tests for the SOCKS5 proxy HTTP module.""" """Tests for the SOCKS5 proxy HTTP/TCP module."""
import ssl import ssl
import urllib.request import urllib.request
from unittest.mock import MagicMock, patch
import socks
from socks import SOCKS5 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: class TestProxyHandler:
@@ -57,3 +65,42 @@ class TestBuildOpener:
opener = build_opener(context=ctx) opener = build_opener(context=ctx)
proxy = [h for h in opener.handlers if isinstance(h, _ProxyHandler)][0] proxy = [h for h in opener.handlers if isinstance(h, _ProxyHandler)][0]
assert proxy._ssl_context is ctx 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