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 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):

View File

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

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

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
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"))

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

View File

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