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:
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user