From 0417165942f3b40c6a06f28220eaa741d271e4fb Mon Sep 17 00:00:00 2001 From: MrIron Date: Fri, 18 Sep 2026 23:34:35 +0200 Subject: [PATCH] fix(cap): send CAP NEW/DEL to cap-notify clients that are still registering cap_new() and cap_del() skipped every connection that was not yet IsUser. A client that sent CAP LS 302 while its server was still bursting with the network saw no sasl in the listing, the path to the SASL server completed before it registered, and the NEW was dropped: it registered without ever learning that sasl exists. This is what clients of a restarted leaf hit when they reconnect while the leaf links. cap-notify is enabled by negotiation (implicitly by CAP LS 302), not by registration, and the spec lets NEW be "sent at any time" with "*" as the target when no nick is available yet. Notify any local user connection, registered or not, that has cap-notify active. Test: a registering client receives NEW and can REQ the capability before CAP END. (cherry picked from commit e0aaf35; tests adapted to the main harness) --- ircd/m_cap.c | 21 +++++- .../test_cap_notify_registering.py | 74 +++++++++++++++++++ 2 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 tests/pr66_capsasl/test_cap_notify_registering.py diff --git a/ircd/m_cap.c b/ircd/m_cap.c index 91cd7e09f..df6410764 100644 --- a/ircd/m_cap.c +++ b/ircd/m_cap.c @@ -364,6 +364,21 @@ static struct subcmd { { "REQ", cap_req } }; +/** Check whether a local connection should be told about NEW/DEL. + * cap-notify takes effect when it is negotiated (implicitly by CAP LS + * 302), not at registration: a client still registering must hear that a + * capability appeared, or it registers without ever learning of it. + * @param[in] acptr Local connection to test. + * @return Non-zero if \a acptr is a (possibly unregistered) user with + * cap-notify active. + */ +static int cap_notify_target(struct Client *acptr) +{ + return MyConnect(acptr) + && (IsUser(acptr) || IsUserPort(acptr) || IsWebsocketPort(acptr)) + && CapHas(cli_active(acptr), CAP_CAPNOTIFY); +} + /** Send CAP NEW to all clients with cap-notify capability * @param[in] cap Capability enum value */ @@ -400,8 +415,7 @@ void cap_new(enum Capab cap) if (!(acptr = LocalClientArray[i])) continue; - /* Only send to registered users with cap-notify capability */ - if (!IsUser(acptr) || !MyConnect(acptr) || !CapHas(cli_active(acptr), CAP_CAPNOTIFY)) + if (!cap_notify_target(acptr)) continue; /* Send CAP NEW message */ @@ -442,8 +456,7 @@ void cap_del(enum Capab cap) if (!(acptr = LocalClientArray[i])) continue; - /* Only send to registered users with cap-notify capability */ - if (!IsUser(acptr) || !MyConnect(acptr) || !CapHas(cli_active(acptr), CAP_CAPNOTIFY)) + if (!cap_notify_target(acptr)) continue; /* Send CAP DEL message */ diff --git a/tests/pr66_capsasl/test_cap_notify_registering.py b/tests/pr66_capsasl/test_cap_notify_registering.py new file mode 100644 index 000000000..9d47dcedb --- /dev/null +++ b/tests/pr66_capsasl/test_cap_notify_registering.py @@ -0,0 +1,74 @@ +"""CAP NEW for a cap-notify client that has not finished registering. + +cap-notify is enabled by negotiation (implicitly by ``CAP LS 302``), not by +registration, and the IRCv3 capability-negotiation spec lets ``CAP NEW`` be +"sent at any time", with ``*`` as the target while no nick is available. + +A restarted leaf takes its clients back while it is still linking to the +network: they see no ``sasl`` in ``CAP LS``, and the SASL server becomes +reachable before they are registered. If the NEW is only sent to registered +users they register without ever learning that sasl exists. +""" + +import asyncio + +import pytest + +from irc_client import IRCClient +from p10_server import P10Server + + +pytestmark = pytest.mark.single_server + +SASL_SERVER = "services.test.net" +MECHANISMS = "PLAIN" + + +async def _collect_cap(client: IRCClient, timeout: float) -> list[tuple[str, str]]: + """Return every (subcommand, argument) CAP message seen within timeout.""" + seen = [] + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while True: + remaining = deadline - loop.time() + if remaining <= 0: + return seen + try: + msg = await client.wait_for("CAP", timeout=remaining) + except asyncio.TimeoutError: + return seen + seen.append((msg.params[1], msg.params[-1])) + + +async def test_cap_new_reaches_client_still_registering(ircd_hub): + client = IRCClient() + await client.connect(ircd_hub["host"], ircd_hub["port"]) + srv = None + try: + await client.send("CAP LS 302") + msg = await client.wait_for("CAP", timeout=5.0) + assert "sasl" not in msg.params[-1], msg.params + # Registration stays suspended: no CAP END yet. + await client.send("NICK capreg1") + await client.send("USER testuser 0 * :Test User") + + # The SASL server links and is configured while we are still + # negotiating. + srv = P10Server(name=SASL_SERVER, numeric=4, password="testpass") + await srv.connect(ircd_hub["host"], ircd_hub["server_port"]) + await srv.handshake() + await srv.send_config("sasl.mechanisms", MECHANISMS) + await srv.send_config("sasl.server", SASL_SERVER) + + caps = await _collect_cap(client, 3.0) + assert caps and caps[-1] == ("NEW", f"sasl={MECHANISMS}"), caps + + # The announced capability is usable before registration completes. + await client.send("CAP REQ :sasl") + msg = await client.wait_for("CAP", timeout=5.0) + assert msg.params[1] == "ACK", msg.params + + finally: + if srv is not None: + await srv.disconnect() + await client.disconnect()