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 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-21 01:39:57 +01:00
parent 2f40f5e508
commit 15f0d374d2
2 changed files with 107 additions and 34 deletions

View File

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

View File

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