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
|
self._reconnect_attempt = 0
|
||||||
await self._go_ready()
|
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:
|
async def _go_ready(self) -> None:
|
||||||
"""Transition to ready: skip NickServ if SASL succeeded, otherwise register.
|
"""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._status(f"SASL {self._sasl_mechanism} authenticated as {self._sasl_nick}")
|
||||||
self._sasl_complete.set()
|
self._sasl_complete.set()
|
||||||
await self.send_raw("CAP", "END")
|
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
|
return
|
||||||
|
|
||||||
if msg.command in ("902", "904", "905"):
|
if msg.command in ("902", "904", "905"):
|
||||||
@@ -816,6 +839,13 @@ class Network:
|
|||||||
reason = msg.params[-1] if msg.params else msg.command
|
reason = msg.params[-1] if msg.params else msg.command
|
||||||
log.warning("[%s] SASL %s failed (%s): %s",
|
log.warning("[%s] SASL %s failed (%s): %s",
|
||||||
self.cfg.name, self._sasl_mechanism, msg.command, reason)
|
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._status(f"SASL {self._sasl_mechanism} failed, falling back")
|
||||||
self._sasl_nick = ""
|
self._sasl_nick = ""
|
||||||
self._sasl_pass = ""
|
self._sasl_pass = ""
|
||||||
@@ -840,6 +870,10 @@ class Network:
|
|||||||
if "@" in hostmask:
|
if "@" in hostmask:
|
||||||
self.visible_host = hostmask.split("@", 1)[1]
|
self.visible_host = hostmask.split("@", 1)[1]
|
||||||
log.info("[%s] visible host: %s", self.cfg.name, self.visible_host)
|
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()
|
await self._enter_probation()
|
||||||
|
|
||||||
elif msg.command == "396":
|
elif msg.command == "396":
|
||||||
|
|||||||
@@ -16,13 +16,21 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
async def _resolve_all(host: str, port: int) -> list[str]:
|
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()
|
loop = asyncio.get_running_loop()
|
||||||
infos = await loop.getaddrinfo(
|
try:
|
||||||
host, port, family=socket.AF_INET, type=socket.SOCK_STREAM,
|
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:
|
if not infos:
|
||||||
raise OSError(f"could not resolve {host}")
|
return []
|
||||||
# Deduplicate while preserving order
|
# Deduplicate while preserving order
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
addrs: list[str] = []
|
addrs: list[str] = []
|
||||||
@@ -35,6 +43,43 @@ async def _resolve_all(host: str, port: int) -> list[str]:
|
|||||||
return addrs
|
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(
|
async def connect(
|
||||||
host: str,
|
host: str,
|
||||||
port: int,
|
port: int,
|
||||||
@@ -44,8 +89,9 @@ 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
|
Resolves hostnames locally first and tries all addresses. If local DNS
|
||||||
SOCKS5 proxies cannot do remote DNS resolution reliably.
|
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.
|
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.
|
||||||
@@ -54,35 +100,28 @@ async def connect(
|
|||||||
addrs = await _resolve_all(host, port)
|
addrs = await _resolve_all(host, port)
|
||||||
last_err: Exception | None = None
|
last_err: Exception | None = None
|
||||||
|
|
||||||
for dest_ip in addrs:
|
if addrs:
|
||||||
proxy = Proxy.from_url(f"socks5://{proxy_cfg.host}:{proxy_cfg.port}")
|
# Local resolution succeeded -- try each IP
|
||||||
log.debug(
|
for dest_ip in addrs:
|
||||||
"trying %s (%s):%d via socks5://%s:%d",
|
try:
|
||||||
host, dest_ip, port, proxy_cfg.host, proxy_cfg.port,
|
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:
|
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:
|
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
|
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
|
raise OSError(f"all addresses for {host} failed via SOCKS5") from last_err
|
||||||
|
|||||||
Reference in New Issue
Block a user