From 15f0d374d2c81e2a6c9e5e587aa7077bb2df1203 Mon Sep 17 00:00:00 2001 From: user Date: Sat, 21 Feb 2026 01:39:57 +0100 Subject: [PATCH] feat: remote DNS fallback, .onion TLS handling, SASL EXTERNAL fallback proxy.py: - Refactor connection logic into _connect_once() helper - Fall back to remote DNS via SOCKS5 when local resolution fails (enables .onion and proxy-only hostnames) - Skip TLS hostname verification for .onion addresses (Tor routing provides authentication) network.py: - Fall back from SASL EXTERNAL to PLAIN on 904 (same connection) - Auto-register cert fingerprint with NickServ CERT ADD immediately after SASL PLAIN success (903) and after RPL_WELCOME (001) Co-Authored-By: Claude Opus 4.6 --- src/bouncer/network.py | 34 +++++++++++++ src/bouncer/proxy.py | 107 ++++++++++++++++++++++++++++------------- 2 files changed, 107 insertions(+), 34 deletions(-) diff --git a/src/bouncer/network.py b/src/bouncer/network.py index 8c2beef..0a83d40 100644 --- a/src/bouncer/network.py +++ b/src/bouncer/network.py @@ -449,6 +449,25 @@ class Network: self._reconnect_attempt = 0 await self._go_ready() + async def _register_cert_fingerprint(self) -> None: + """Register cert fingerprint with NickServ if a cert exists. + + Called immediately after SASL PLAIN success so the fingerprint is + registered before a potential K-line disconnects us. + """ + from bouncer.cert import fingerprint, has_cert, cert_path + + nick = self._sasl_nick or self.nick + if not has_cert(self.data_dir, self.cfg.name, nick): + return + + pem = cert_path(self.data_dir, self.cfg.name, nick) + fp = fingerprint(pem) + log.info("[%s] registering cert fingerprint with NickServ: %s", + self.cfg.name, fp) + self._status(f"registering cert fingerprint for {nick}") + await self.send_raw("PRIVMSG", "NickServ", f"CERT ADD {fp}") + async def _go_ready(self) -> None: """Transition to ready: skip NickServ if SASL succeeded, otherwise register. @@ -809,6 +828,10 @@ class Network: self._status(f"SASL {self._sasl_mechanism} authenticated as {self._sasl_nick}") self._sasl_complete.set() await self.send_raw("CAP", "END") + # If authenticated via PLAIN but a cert exists, register fingerprint + # immediately (before K-line can disconnect us) + if self._sasl_mechanism == "PLAIN" and self.data_dir: + await self._register_cert_fingerprint() return if msg.command in ("902", "904", "905"): @@ -816,6 +839,13 @@ class Network: reason = msg.params[-1] if msg.params else msg.command log.warning("[%s] SASL %s failed (%s): %s", self.cfg.name, self._sasl_mechanism, msg.command, reason) + # EXTERNAL failed but we have PLAIN creds -- retry on same connection + if self._sasl_mechanism == "EXTERNAL" and self._sasl_pass: + self._sasl_mechanism = "PLAIN" + self._status("SASL EXTERNAL failed, trying PLAIN") + log.info("[%s] falling back to SASL PLAIN", self.cfg.name) + await self.send_raw("AUTHENTICATE", "PLAIN") + return self._status(f"SASL {self._sasl_mechanism} failed, falling back") self._sasl_nick = "" self._sasl_pass = "" @@ -840,6 +870,10 @@ class Network: if "@" in hostmask: self.visible_host = hostmask.split("@", 1)[1] log.info("[%s] visible host: %s", self.cfg.name, self.visible_host) + # Register cert fingerprint immediately after 001 (before K-line) + if self._sasl_complete.is_set() and self._sasl_mechanism == "PLAIN": + if self.data_dir: + await self._register_cert_fingerprint() await self._enter_probation() elif msg.command == "396": diff --git a/src/bouncer/proxy.py b/src/bouncer/proxy.py index 8541125..06b3570 100644 --- a/src/bouncer/proxy.py +++ b/src/bouncer/proxy.py @@ -16,13 +16,21 @@ log = logging.getLogger(__name__) async def _resolve_all(host: str, port: int) -> list[str]: - """Resolve hostname to all IPv4 addresses locally.""" + """Resolve hostname to all IPv4 addresses locally. + + Returns an empty list if local resolution fails (the caller should + fall back to remote resolution via the proxy). + """ loop = asyncio.get_running_loop() - infos = await loop.getaddrinfo( - host, port, family=socket.AF_INET, type=socket.SOCK_STREAM, - ) + try: + infos = await loop.getaddrinfo( + host, port, family=socket.AF_INET, type=socket.SOCK_STREAM, + ) + except OSError: + log.debug("local DNS failed for %s, will use remote resolution", host) + return [] if not infos: - raise OSError(f"could not resolve {host}") + return [] # Deduplicate while preserving order seen: set[str] = set() addrs: list[str] = [] @@ -35,6 +43,43 @@ async def _resolve_all(host: str, port: int) -> list[str]: return addrs +async def _connect_once( + proxy_cfg: ProxyConfig, + dest_host: str, + dest_port: int, + tls: bool, + server_hostname: str, + client_cert: Path | None, +) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + """Connect through SOCKS5 to a single destination.""" + proxy = Proxy.from_url(f"socks5://{proxy_cfg.host}:{proxy_cfg.port}") + log.debug( + "trying %s:%d via socks5://%s:%d", + dest_host, dest_port, proxy_cfg.host, proxy_cfg.port, + ) + sock = await proxy.connect(dest_host=dest_host, dest_port=dest_port) + + ssl_ctx: ssl.SSLContext | None = None + if tls: + ssl_ctx = ssl.create_default_context() + # Onion addresses are authenticated by Tor routing; skip hostname check + if server_hostname.endswith(".onion"): + ssl_ctx.check_hostname = False + ssl_ctx.verify_mode = ssl.CERT_NONE + if client_cert: + ssl_ctx.load_cert_chain(certfile=str(client_cert)) + log.debug("loaded client cert %s", client_cert) + + reader, writer = await asyncio.open_connection( + host=None, + port=None, + sock=sock, + ssl=ssl_ctx, + server_hostname=server_hostname if tls else None, + ) + return reader, writer + + async def connect( host: str, port: int, @@ -44,8 +89,9 @@ 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. + Resolves hostnames locally first and tries all addresses. If local DNS + fails (e.g. onion addresses, proxy-only hostnames), falls back to remote + resolution by passing the hostname directly to the SOCKS5 proxy. Returns an (asyncio.StreamReader, asyncio.StreamWriter) pair. If tls=True, the connection is wrapped in SSL after the SOCKS5 handshake. @@ -54,35 +100,28 @@ async def connect( 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}") - log.debug( - "trying %s (%s):%d via socks5://%s:%d", - host, dest_ip, port, proxy_cfg.host, proxy_cfg.port, - ) + if addrs: + # Local resolution succeeded -- try each IP + for dest_ip in addrs: + try: + reader, writer = await _connect_once( + proxy_cfg, dest_ip, port, tls, host, client_cert, + ) + log.debug("connected to %s (%s):%d (tls=%s)", host, dest_ip, port, tls) + return reader, writer + except Exception as e: + log.debug("failed to connect via %s: %s", dest_ip, e) + last_err = e + else: + # Local resolution failed -- let the proxy resolve the hostname try: - sock = await proxy.connect(dest_host=dest_ip, dest_port=port) + reader, writer = await _connect_once( + proxy_cfg, host, port, tls, host, client_cert, + ) + log.debug("connected to %s:%d via remote DNS (tls=%s)", host, port, tls) + return reader, writer except Exception as e: - log.debug("failed to connect via %s: %s", dest_ip, e) + log.debug("failed to connect via remote DNS for %s: %s", host, e) last_err = e - continue - - ssl_ctx: ssl.SSLContext | None = None - if tls: - ssl_ctx = ssl.create_default_context() - if client_cert: - ssl_ctx.load_cert_chain(certfile=str(client_cert)) - log.debug("loaded client cert %s", client_cert) - - reader, writer = await asyncio.open_connection( - host=None, - port=None, - sock=sock, - 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 raise OSError(f"all addresses for {host} failed via SOCKS5") from last_err