diff --git a/.gitignore b/.gitignore index 21a1216..548cdb8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ build/ .pytest_cache/ .mypy_cache/ *.log + +# Personal config (keep example only) +config/bouncer.toml diff --git a/src/bouncer/proxy.py b/src/bouncer/proxy.py index f21cb52..33669fd 100644 --- a/src/bouncer/proxy.py +++ b/src/bouncer/proxy.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import logging +import socket import ssl from python_socks.async_.asyncio import Proxy @@ -13,6 +14,26 @@ from bouncer.config import ProxyConfig 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( host: str, port: int, @@ -21,26 +42,41 @@ async def connect( ) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: """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. If tls=True, the connection is wrapped in SSL after the SOCKS5 handshake. """ - proxy = Proxy.from_url(f"socks5://{proxy_cfg.host}:{proxy_cfg.port}") + addrs = await _resolve_all(host, port) + last_err: Exception | None = None - log.debug("connecting to %s:%d via socks5://%s:%d", host, port, proxy_cfg.host, proxy_cfg.port) + for dest_ip in addrs: + proxy = Proxy.from_url(f"socks5://{proxy_cfg.host}:{proxy_cfg.port}") + log.debug( + "trying %s (%s):%d via socks5://%s:%d", + host, dest_ip, port, proxy_cfg.host, proxy_cfg.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 - sock = await proxy.connect(dest_host=host, dest_port=port) + ssl_ctx: ssl.SSLContext | None = None + if tls: + ssl_ctx = ssl.create_default_context() - ssl_ctx: ssl.SSLContext | None = None - if tls: - ssl_ctx = ssl.create_default_context() + reader, writer = await asyncio.open_connection( + host=None, + port=None, + sock=sock, + ssl=ssl_ctx, + server_hostname=host if tls else None, + ) - reader, writer = await asyncio.open_connection( - host=None, - port=None, - sock=sock.socket, - ssl=ssl_ctx, - server_hostname=host if tls else None, - ) + log.debug("connected to %s (%s):%d (tls=%s)", host, dest_ip, port, tls) + return reader, writer - log.debug("connected to %s:%d (tls=%s)", host, port, tls) - return reader, writer + raise OSError(f"all addresses for {host} failed via SOCKS5") from last_err