fix: resolve DNS locally and try all IPs via SOCKS5

Many SOCKS5 proxies cannot resolve hostnames reliably. Resolve
locally and iterate through all returned addresses until one
succeeds. Also exclude personal config from git.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-19 11:54:30 +01:00
parent ced6232373
commit 41ba680dcb
2 changed files with 54 additions and 15 deletions
+3
View File
@@ -15,3 +15,6 @@ build/
.pytest_cache/ .pytest_cache/
.mypy_cache/ .mypy_cache/
*.log *.log
# Personal config (keep example only)
config/bouncer.toml
+42 -6
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import socket
import ssl import ssl
from python_socks.async_.asyncio import Proxy from python_socks.async_.asyncio import Proxy
@@ -13,6 +14,26 @@ from bouncer.config import ProxyConfig
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
async def _resolve_all(host: str, port: int) -> list[str]:
"""Resolve hostname to all IPv4 addresses locally."""
loop = asyncio.get_running_loop()
infos = await loop.getaddrinfo(
host, port, family=socket.AF_INET, type=socket.SOCK_STREAM,
)
if not infos:
raise OSError(f"could not resolve {host}")
# Deduplicate while preserving order
seen: set[str] = set()
addrs: list[str] = []
for info in infos:
addr = info[4][0]
if addr not in seen:
seen.add(addr)
addrs.append(addr)
log.debug("resolved %s -> %s", host, ", ".join(addrs))
return addrs
async def connect( async def connect(
host: str, host: str,
port: int, port: int,
@@ -21,14 +42,27 @@ async def connect(
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: ) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
"""Open a TCP connection through the SOCKS5 proxy. """Open a TCP connection through the SOCKS5 proxy.
Resolves hostnames locally and tries all addresses, since many
SOCKS5 proxies cannot do remote DNS resolution reliably.
Returns an (asyncio.StreamReader, asyncio.StreamWriter) pair. Returns an (asyncio.StreamReader, asyncio.StreamWriter) pair.
If tls=True, the connection is wrapped in SSL after the SOCKS5 handshake. If tls=True, the connection is wrapped in SSL after the SOCKS5 handshake.
""" """
addrs = await _resolve_all(host, port)
last_err: Exception | None = None
for dest_ip in addrs:
proxy = Proxy.from_url(f"socks5://{proxy_cfg.host}:{proxy_cfg.port}") proxy = Proxy.from_url(f"socks5://{proxy_cfg.host}:{proxy_cfg.port}")
log.debug(
log.debug("connecting to %s:%d via socks5://%s:%d", host, port, proxy_cfg.host, proxy_cfg.port) "trying %s (%s):%d via socks5://%s:%d",
host, dest_ip, port, proxy_cfg.host, proxy_cfg.port,
sock = await proxy.connect(dest_host=host, dest_port=port) )
try:
sock = await proxy.connect(dest_host=dest_ip, dest_port=port)
except Exception as e:
log.debug("failed to connect via %s: %s", dest_ip, e)
last_err = e
continue
ssl_ctx: ssl.SSLContext | None = None ssl_ctx: ssl.SSLContext | None = None
if tls: if tls:
@@ -37,10 +71,12 @@ async def connect(
reader, writer = await asyncio.open_connection( reader, writer = await asyncio.open_connection(
host=None, host=None,
port=None, port=None,
sock=sock.socket, sock=sock,
ssl=ssl_ctx, ssl=ssl_ctx,
server_hostname=host if tls else None, server_hostname=host if tls else None,
) )
log.debug("connected to %s:%d (tls=%s)", host, port, tls) log.debug("connected to %s (%s):%d (tls=%s)", host, dest_ip, port, tls)
return reader, writer return reader, writer
raise OSError(f"all addresses for {host} failed via SOCKS5") from last_err